Restrict initial location for ACTION_OPEN_DOCUMENT/_TREE

Implement privacy restriction introduced in Android 11 that application
are not allowed to request initial location for intent actions
ACTION_OPEN_DOCUMENT and ACTION_OPEN_DOCUMENT_TREE to be /Android/data/,
/Android/obb/, /Android/sandbox/ and all their subdirectories.
If an application does request the initial location to be one of these
directories (or their subdirs) redirect to the default location - last
accessed stack.

Bug: 200034476
Bug: 220066255
Test: atest
  DocumentsUIGoogleTests:com.android.documentsui.picker.ActionHandlerTest
Test: adb shell am start
  -a android.intent.action.OPEN_DOCUMENT_TREE
  --eu android.provider.extra.INITIAL_URI
  "content://com.android.externalstorage.documents/document/primary%3AAndroid%2Fdata"
Change-Id: I7e31a8fb76b5ddb0e3af67852b1e1ccc9a825648
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java
index a310ace..4407a62 100644
--- a/src/com/android/documentsui/AbstractActionHandler.java
+++ b/src/com/android/documentsui/AbstractActionHandler.java
@@ -779,32 +779,48 @@
     }
 
     protected final boolean launchToDocument(Uri uri) {
-        // We don't support launching to a document in an archive.
-        if (!Providers.isArchiveUri(uri)) {
-            loadDocument(uri, UserId.DEFAULT_USER, this::onStackLoaded);
-            return true;
+        if (DEBUG) {
+            Log.d(TAG, "launchToDocument() uri=" + uri);
         }
 
-        return false;
+        // We don't support launching to a document in an archive.
+        if (Providers.isArchiveUri(uri)) {
+            return false;
+        }
+
+        loadDocument(uri, UserId.DEFAULT_USER, this::onStackToLaunchToLoaded);
+        return true;
     }
 
-    private void onStackLoaded(@Nullable DocumentStack stack) {
-        if (stack != null) {
-            if (!stack.peek().isDirectory()) {
-                // Requested document is not a directory. Pop it so that we can launch into its
-                // parent.
-                stack.pop();
-            }
-            mState.stack.reset(stack);
-            mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
+    /**
+     * Invoked <b>only</b> once, when the initial stack (that is the stack we are going to
+     * "launch to") is loaded.
+     *
+     * @see #launchToDocument(Uri)
+     */
+    private void onStackToLaunchToLoaded(@Nullable DocumentStack stack) {
+        if (DEBUG) {
+            Log.d(TAG, "onLaunchStackLoaded() stack=" + stack);
+        }
 
-            Metrics.logLaunchAtLocation(mState, stack.getRoot().getUri());
-        } else {
+        if (stack == null) {
             Log.w(TAG, "Failed to launch into the given uri. Launch to default location.");
             launchToDefaultLocation();
 
             Metrics.logLaunchAtLocation(mState, null);
+            return;
         }
+
+        // Make sure the document at the top of the stack is a directory (if it isn't - just pop
+        // one off).
+        if (!stack.peek().isDirectory()) {
+            stack.pop();
+        }
+
+        mState.stack.reset(stack);
+        mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE);
+
+        Metrics.logLaunchAtLocation(mState, stack.getRoot().getUri());
     }
 
     private void onRootLoaded(@Nullable RootInfo root) {
diff --git a/src/com/android/documentsui/picker/ActionHandler.java b/src/com/android/documentsui/picker/ActionHandler.java
index cc349a8..1fc35b3 100644
--- a/src/com/android/documentsui/picker/ActionHandler.java
+++ b/src/com/android/documentsui/picker/ActionHandler.java
@@ -16,6 +16,9 @@
 
 package com.android.documentsui.picker;
 
+import static android.provider.DocumentsContract.isDocumentUri;
+import static android.provider.DocumentsContract.isRootUri;
+
 import static com.android.documentsui.base.SharedMinimal.DEBUG;
 import static com.android.documentsui.base.State.ACTION_CREATE;
 import static com.android.documentsui.base.State.ACTION_GET_CONTENT;
@@ -23,6 +26,8 @@
 import static com.android.documentsui.base.State.ACTION_OPEN_TREE;
 import static com.android.documentsui.base.State.ACTION_PICK_COPY_DESTINATION;
 
+import static java.util.regex.Pattern.CASE_INSENSITIVE;
+
 import android.content.ActivityNotFoundException;
 import android.content.ClipData;
 import android.content.ComponentName;
@@ -35,6 +40,8 @@
 import android.provider.Settings;
 import android.util.Log;
 
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
 import androidx.fragment.app.FragmentActivity;
 import androidx.fragment.app.FragmentManager;
@@ -52,6 +59,7 @@
 import com.android.documentsui.base.DocumentStack;
 import com.android.documentsui.base.Features;
 import com.android.documentsui.base.Lookup;
+import com.android.documentsui.base.Providers;
 import com.android.documentsui.base.RootInfo;
 import com.android.documentsui.base.Shared;
 import com.android.documentsui.base.State;
@@ -61,14 +69,12 @@
 import com.android.documentsui.queries.SearchViewManager;
 import com.android.documentsui.roots.ProvidersAccess;
 import com.android.documentsui.services.FileOperationService;
+import com.android.documentsui.util.FileUtils;
 
-import com.android.documentsui.util.VersionUtils;
+import java.io.IOException;
 import java.util.Arrays;
-import java.util.Locale;
 import java.util.concurrent.Executor;
-
 import java.util.regex.Pattern;
-import javax.annotation.Nullable;
 
 /**
  * Provides {@link PickActivity} action specializations to fragments.
@@ -77,12 +83,24 @@
 
     private static final String TAG = "PickerActionHandler";
 
+    /**
+     * Used to prevent applications from using {@link Intent.ACTION_OPEN_DOCUMENT_TREE} and
+     * the {@link Intent.ACTION_OPEN_DOCUMENT} actions to request that the user select individual
+     * files from "/Android/data", "/Android/obb", "/Android/sandbox" directories and all their
+     * subdirectories (on the external storage), in accordance with the SAF privacy restrictions
+     * introduced in Android 11 (R).
+     *
+     * <p>
+     * See <a href="https://developer.android.com/about/versions/11/privacy/storage#file-access">
+     * Storage updates in Android 11</a>.
+     */
+    private static final Pattern PATTERN_RESTRICTED_INITIAL_PATH =
+            Pattern.compile("^/Android/(?:data|obb|sandbox).*", CASE_INSENSITIVE);
+
     private final Features mFeatures;
     private final ActivityConfig mConfig;
     private final LastAccessedStorage mLastAccessed;
     private final UserIdManager mUserIdManager;
-    private final static Pattern PATTERN_BLOCK_PATH = Pattern.compile(
-        ".*:android\\/(?:data|obb|sandbox)$");
 
     private UpdatePickResultTask mUpdatePickResultTask;
 
@@ -160,25 +178,84 @@
     }
 
     private boolean launchToInitialUri(Intent intent) {
-        Uri uri = intent.getParcelableExtra(DocumentsContract.EXTRA_INITIAL_URI);
-        if (uri != null) {
-            // In android S and above if path contains Android/data, Android/obb
-            // or Android/sandbox redirect to the root for which
-            // FLAG_DIR_BLOCKS_OPEN_DOCUMENT_TREE is already set
-            if(Shared.shouldRestrictStorageAccessFramework(mActivity)
-                && (PATTERN_BLOCK_PATH.matcher(uri.getPath().toLowerCase(Locale.ROOT)).matches())){
-                loadDeviceRoot();
-                return true;
-            }
-            if (DocumentsContract.isRootUri(mActivity, uri)) {
-                loadRoot(uri, UserId.DEFAULT_USER);
-                return true;
-            } else if (DocumentsContract.isDocumentUri(mActivity, uri)) {
-                return launchToDocument(uri);
-            }
+        final Uri initialUri = intent.getParcelableExtra(DocumentsContract.EXTRA_INITIAL_URI);
+        if (initialUri == null) {
+            return false;
         }
 
-        return false;
+        final boolean isRoot = isRootUri(mActivity, initialUri);
+        final boolean isDocument = !isRoot && isDocumentUri(mActivity, initialUri);
+
+        if (!isRoot && !isDocument) {
+            // Neither a root nor a document.
+            return false;
+        }
+
+        if (isRoot) {
+            loadRoot(initialUri, UserId.DEFAULT_USER);
+            return true;
+        }
+        // From here onwards: isDoc == true.
+
+        if (shouldPreemptivelyRestrictRequestedInitialUri(initialUri)) {
+            Log.w(TAG, "Requested initial URI - " + initialUri + " - is restricted: "
+                    + "loading device root instead.");
+            return false;
+        }
+
+        return launchToDocument(initialUri);
+    }
+
+    /**
+     * Starting with Android 11 (R, API Level 30) applications are no longer allowed to use the
+     * {@link Intent#ACTION_OPEN_DOCUMENT} and {@link Intent#ACTION_OPEN_DOCUMENT_TREE} to request
+     * that the user select individual files from "Android/data/", "Android/obb/",
+     * "Android/sandbox/" directories and all their subdirectories on "external storage".
+     * <p>
+     * See <a href="https://developer.android.com/about/versions/11/privacy/storage#file-access">
+     * Storage updates in Android 11</a>.
+     * <p>
+     * Ideally, this should be handled on the {@code ExternalStorageProvider} side, but as of
+     * Android 14 (U) FRC, {@code ExternalStorageProvider} "hides" only "Android/data/",
+     * "Android/obb/" and "Android/sandbox/" directories, but NOT their subdirectories.
+     */
+    private boolean shouldPreemptivelyRestrictRequestedInitialUri(@NonNull Uri uri) {
+        // Not restricting SAF access for the calling app.
+        if (!Shared.shouldRestrictStorageAccessFramework(mActivity)) {
+            return false;
+        }
+
+        // We only need to restrict some locations on the "external" storage.
+        if (!Providers.AUTHORITY_STORAGE.equals(uri.getAuthority())) {
+            return false;
+        }
+
+        // TODO(b/283962634): in the future this will have to be platform-version specific.
+        //  For example, if the fix on the ExternalStorageProvider side makes it to the Android 15,
+        //  we would change this to check if the platform version >= 15.
+        //  In the upcoming Android 14 release, however, ExternalStorageProvider does NOT yet
+        //  implement this logic.
+        final boolean externalProviderImplementsSafRestrictions = false;
+        if (externalProviderImplementsSafRestrictions) {
+            return false;
+        }
+
+        // External Storage Provider's docId format is "root:path/to/file"
+        // The getPathFromStorageDocId() turns that into "/path/to/file"
+        // Note the missing leading "/" in the path part of the docId, while the path returned by
+        // the getPathFromStorageDocId() start with "/".
+        final String docId = DocumentsContract.getDocumentId(uri);
+        final String filePath;
+        try {
+             filePath = FileUtils.getPathFromStorageDocId(docId);
+        } catch (IOException e) {
+            Log.w(TAG, "Could not get canonical file path from docId '" + docId + "'");
+            return true;
+        }
+
+        // Check if the app is asking for /Android/data, /Android/obb, /Android/sandbox or any of
+        // their subdirectories (on the external storage).
+        return PATTERN_RESTRICTED_INITIAL_PATH.matcher(filePath).matches();
     }
 
     private void initLoadLastAccessedStack() {
diff --git a/src/com/android/documentsui/util/FileUtils.java b/src/com/android/documentsui/util/FileUtils.java
new file mode 100644
index 0000000..40f7fba
--- /dev/null
+++ b/src/com/android/documentsui/util/FileUtils.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2023 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 com.android.documentsui.util;
+
+import androidx.annotation.NonNull;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Objects;
+
+public class FileUtils {
+
+    /**
+     * Returns the canonical pathname string of the provided abstract pathname.
+     *
+     * @return The canonical pathname string denoting the same file or directory as this abstract
+     *         pathname.
+     * @see File#getCanonicalPath()
+     */
+    @NonNull
+    public static String getCanonicalPath(@NonNull String path) throws IOException {
+        Objects.requireNonNull(path);
+        return new File(path).getCanonicalPath();
+    }
+
+    /**
+     * This is basically a very slightly tweaked fork of
+     * {@link com.android.externalstorage.ExternalStorageProvider#getPathFromDocId(String)}.
+     * The difference between this fork and the "original" method is that here we do not strip
+     * the leading and trailing "/"s (because we don't worry about those).
+     *
+     * @return canonicalized file path.
+     */
+    public static String getPathFromStorageDocId(String docId) throws IOException {
+        // Remove the root tag from the docId, e.g. "primary:", which should leave with the file
+        // path.
+        final String docIdPath = docId.substring(docId.indexOf(':', 1) + 1);
+
+        return getCanonicalPath(docIdPath);
+    }
+
+    private FileUtils() {
+    }
+}