DO NOT MERGE Add directory access details page.

This will eventually be reachable through Special app access -> Directory access by selecting an app in the directory access list (split for CL size). It is based primarily on com.android.settings.applications.DirectoryAccessDetails.

Bug: 122824071

Test: tested end to end on full feature CL, RunCarSettingsRoboTests
Change-Id: Iba9a4edbf509e076a24383edeec4c74a300c5126
diff --git a/res/values/preference_keys.xml b/res/values/preference_keys.xml
index 2690118..822a49c 100644
--- a/res/values/preference_keys.xml
+++ b/res/values/preference_keys.xml
@@ -64,7 +64,8 @@
     <string name="pk_data_limit" translatable="false">data_limit</string>
     <string name="pk_app_data_usage" translatable="false">app_data_usage</string>
     <string name="pk_app_data_usage_detail" translatable="false">app_data_usage_detail</string>
-    <string name="pk_wifi_tether_settings_entry" translatable="false">wifi_tether_settings_entry</string>
+    <string name="pk_wifi_tether_settings_entry" translatable="false">wifi_tether_settings_entry
+    </string>
     <string name="pk_wifi_tether_security" translatable="false">wifi_tether_security</string>
     <string name="pk_wifi_tether_ap_band" translatable="false">wifi_tether_ap_band</string>
     <string name="pk_wifi_tether_auto_off" translatable="false">wifi_tether_auto_off</string>
@@ -190,6 +191,11 @@
     <string name="pk_usage_access" translatable="false">usage_access</string>
     <string name="pk_usage_access_description" translatable="false">usage_access_description
     </string>
+    <string name="pk_directory_access_details" translatable="false">directory_access_details
+    </string>
+    <string name="pk_directory_access_details_app" translatable="false">
+        directory_access_details_app
+    </string>
     <string name="pk_wifi_control_entry" translatable="false">wifi_control_entry</string>
     <string name="pk_wifi_control" translatable="false">wifi_control</string>
     <string name="pk_wifi_control_description" translatable="false">wifi_control_description
diff --git a/res/values/strings.xml b/res/values/strings.xml
index cfa2779..a0491db 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -507,6 +507,10 @@
     <string name="usage_access_title">Usage access</string>
     <!-- Description of the usage access permission. [CHAR_LIMIT=NONE] -->
     <string name="usage_access_description">Usage access allows an app to track what other apps you\u2019re using and how often, as well as your carrier, language settings, and other details.</string>
+    <!-- Title for managing external storage directory access settings. [CHAR_LIMIT=30] -->
+    <string name="directory_access_title">Directory access</string>
+    <!-- String used to describe the name of a directory on a particular volume. Example: SD Card (Movies). [CHAR_LIMIT=50] -->
+    <string name="directory_on_volume"><xliff:g id="volume" example="SD Card">%1$s</xliff:g> (<xliff:g id="directory" example="Movies">%2$s</xliff:g>)</string>
     <!-- Title for managing apps which can change Wi-Fi state. [CHAR_LIMIT=30] -->
     <string name="wifi_control_title">Wi-Fi control</string>
     <!-- Description of the change wifi state permission. [CHAR_LIMIT=NONE] -->
diff --git a/res/xml/directory_access_details_fragment.xml b/res/xml/directory_access_details_fragment.xml
new file mode 100644
index 0000000..f773dd0
--- /dev/null
+++ b/res/xml/directory_access_details_fragment.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+    Copyright 2019 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.
+-->
+
+<PreferenceScreen
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:settings="http://schemas.android.com/apk/res-auto"
+    android:title="@string/directory_access_title">
+    <Preference
+        android:key="@string/pk_directory_access_details_app"
+        android:selectable="false"
+        settings:controller="com.android.car.settings.applications.ApplicationPreferenceController"/>
+    <com.android.car.settings.common.LogicalPreferenceGroup
+        android:key="@string/pk_directory_access_details"
+        settings:controller="com.android.car.settings.applications.specialaccess.DirectoryAccessDetailsPreferenceController"/>
+</PreferenceScreen>
diff --git a/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsFragment.java b/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsFragment.java
new file mode 100644
index 0000000..ac9d1d8
--- /dev/null
+++ b/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsFragment.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2019 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.car.settings.applications.specialaccess;
+
+import android.car.userlib.CarUserManagerHelper;
+import android.content.Context;
+import android.os.Bundle;
+
+import androidx.annotation.XmlRes;
+
+import com.android.car.settings.R;
+import com.android.car.settings.applications.ApplicationPreferenceController;
+import com.android.car.settings.common.SettingsFragment;
+import com.android.settingslib.applications.ApplicationsState;
+
+import java.util.ArrayList;
+
+/** Displays directory access permissions for a specific package. */
+public class DirectoryAccessDetailsFragment extends SettingsFragment {
+
+    public static final String ARG_PACKAGE_NAME = "package";
+
+    private CarUserManagerHelper mCarUserManagerHelper;
+    private String mPackageName;
+    private ApplicationsState mAppState;
+    private ApplicationsState.Session mSession;
+    private ApplicationsState.AppEntry mAppEntry;
+
+    /** Creates an instance of this fragment, passing {@code packageName} as an argument. */
+    public static DirectoryAccessDetailsFragment getInstance(String packageName) {
+        DirectoryAccessDetailsFragment directoryAccessDetailsFragment =
+                new DirectoryAccessDetailsFragment();
+        Bundle bundle = new Bundle();
+        bundle.putString(ARG_PACKAGE_NAME, packageName);
+        directoryAccessDetailsFragment.setArguments(bundle);
+        return directoryAccessDetailsFragment;
+    }
+
+    @Override
+    @XmlRes
+    protected int getPreferenceScreenResId() {
+        return R.xml.directory_access_details_fragment;
+    }
+
+    @Override
+    public void onAttach(Context context) {
+        super.onAttach(context);
+        mCarUserManagerHelper = new CarUserManagerHelper(context);
+
+        mPackageName = getArguments().getString(ARG_PACKAGE_NAME);
+
+        mAppState = ApplicationsState.getInstance(requireActivity().getApplication());
+        mSession = mAppState.newSession(mApplicationStateCallbacks, getLifecycle());
+
+        retrieveAppEntry();
+
+        use(ApplicationPreferenceController.class,
+                R.string.pk_directory_access_details_app).setAppEntry(mAppEntry).setAppState(
+                mAppState);
+        use(DirectoryAccessDetailsPreferenceController.class,
+                R.string.pk_directory_access_details).setPackage(mPackageName);
+    }
+
+    @Override
+    public void onStart() {
+        super.onStart();
+        // Resume the session earlier than the lifecycle so that cached information is updated
+        // even if settings is not resumed (for example in multi-display).
+        mSession.onResume();
+        refresh();
+    }
+
+    @Override
+    public void onStop() {
+        super.onStop();
+        // Since we resume early in onStart, make sure we clean up even if we don't receive onPause.
+        mSession.onPause();
+    }
+
+    private void refresh() {
+        retrieveAppEntry();
+        if (mAppEntry == null) {
+            goBack();
+        }
+    }
+
+    private void retrieveAppEntry() {
+        mAppEntry = mAppState.getEntry(mPackageName,
+                mCarUserManagerHelper.getCurrentProcessUserId());
+    }
+
+    private final ApplicationsState.Callbacks mApplicationStateCallbacks =
+            new ApplicationsState.Callbacks() {
+                @Override
+                public void onRunningStateChanged(boolean running) {
+                }
+
+                @Override
+                public void onPackageListChanged() {
+                    refresh();
+                }
+
+                @Override
+                public void onRebuildComplete(ArrayList<ApplicationsState.AppEntry> apps) {
+                }
+
+                @Override
+                public void onPackageIconChanged() {
+                }
+
+                @Override
+                public void onPackageSizeChanged(String packageName) {
+                }
+
+                @Override
+                public void onAllSizesComputed() {
+                }
+
+                @Override
+                public void onLauncherInfoChanged() {
+                }
+
+                @Override
+                public void onLoadEntriesCompleted() {
+                }
+            };
+}
diff --git a/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsPreferenceController.java b/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsPreferenceController.java
new file mode 100644
index 0000000..e565e91
--- /dev/null
+++ b/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsPreferenceController.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright 2019 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.car.settings.applications.specialaccess;
+
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.AUTHORITY;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.COL_GRANTED;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COLUMNS;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COL_DIRECTORY;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COL_GRANTED;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COL_PACKAGE;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COL_VOLUME_UUID;
+
+import android.annotation.Nullable;
+import android.car.drivingstate.CarUxRestrictions;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.storage.StorageManager;
+import android.os.storage.VolumeInfo;
+import android.util.ArrayMap;
+import android.util.Pair;
+
+import androidx.preference.PreferenceGroup;
+import androidx.preference.SwitchPreference;
+
+import com.android.car.settings.R;
+import com.android.car.settings.common.FragmentController;
+import com.android.car.settings.common.Logger;
+import com.android.car.settings.common.PreferenceController;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Detailed settings for an app's directory access permissions (A.K.A Scoped Directory Access).
+ *
+ * <p>It shows the directories for which the user denied access with the "Do not ask again" flag.
+ * The user can use the preference toggles to grant access again.
+ *
+ * <p>This controller dynamically lists all such permissions starting with one preference per
+ * directory in the primary storage then adding additional preferences for external volumes (one
+ * for the whole volume and one for each individual directory). Granting access to a whole volume
+ * will hide individual directory permissions.
+ */
+public class DirectoryAccessDetailsPreferenceController extends
+        PreferenceController<PreferenceGroup> {
+
+    private static final Logger LOG = new Logger(DirectoryAccessDetailsPreferenceController.class);
+
+    private String mPackageName;
+
+    public DirectoryAccessDetailsPreferenceController(Context context, String preferenceKey,
+            FragmentController fragmentController, CarUxRestrictions uxRestrictions) {
+        super(context, preferenceKey, fragmentController, uxRestrictions);
+    }
+
+    @Override
+    protected Class<PreferenceGroup> getPreferenceType() {
+        return PreferenceGroup.class;
+    }
+
+    /**
+     * Sets the package for which to display directory access. This should be called right after the
+     * controller is instantiated.
+     */
+    public void setPackage(String packageName) {
+        mPackageName = packageName;
+    }
+
+    @Override
+    protected void checkInitialized() {
+        if (mPackageName == null) {
+            throw new IllegalStateException("Must specify package for directory access details");
+        }
+    }
+
+    @Override
+    protected void updateState(PreferenceGroup preferenceGroup) {
+        preferenceGroup.removeAll();
+        preferenceGroup.setOrderingAsAdded(false);
+
+        Map<String, ExternalVolume> externalVolumes = new ArrayMap<>();
+        Uri providerUri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT).authority(
+                AUTHORITY).appendPath(TABLE_PERMISSIONS).appendPath("*").build();
+        // Query provider for entries.
+        try (Cursor cursor = getContext().getContentResolver().query(providerUri,
+                TABLE_PERMISSIONS_COLUMNS, /* selection= */ null,
+                new String[]{mPackageName}, /* sortOrder= */ null)) {
+            if (cursor == null) {
+                LOG.w("Didn't get cursor for " + mPackageName);
+                return;
+            }
+            int count = cursor.getCount();
+            if (count == 0) {
+                // This setting screen should not be reached if there was no permission, so just
+                // ignore it.
+                LOG.w("No permissions for " + mPackageName);
+                return;
+            }
+
+            while (cursor.moveToNext()) {
+                String pkg = cursor.getString(TABLE_PERMISSIONS_COL_PACKAGE);
+                String uuid = cursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID);
+                String dir = cursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY);
+                boolean granted = cursor.getInt(TABLE_PERMISSIONS_COL_GRANTED) == 1;
+                LOG.v("Pkg:" + pkg + " uuid: " + uuid + " dir: " + dir + " granted:" + granted);
+
+                if (!mPackageName.equals(pkg)) {
+                    // Sanity check, shouldn't happen.
+                    LOG.w("Ignoring " + uuid + "/" + dir + " due to package mismatch: "
+                            + "expected " + mPackageName + ", got " + pkg);
+                    continue;
+                }
+
+                if (uuid == null) {
+                    if (dir == null) {
+                        // Sanity check, shouldn't happen.
+                        LOG.wtf("Ignoring permission on primary storage root");
+                    } else {
+                        // Primary storage entry: add right away
+                        preferenceGroup.addPreference(
+                                createPreference(dir, providerUri, /* uuid= */ null, dir,
+                                        granted));
+                    }
+                } else {
+                    // External volume entry: save it for later.
+                    ExternalVolume externalVolume = externalVolumes.get(uuid);
+                    if (externalVolume == null) {
+                        externalVolume = new ExternalVolume(uuid);
+                        externalVolumes.put(uuid, externalVolume);
+                    }
+                    if (dir == null) {
+                        // Whole volume.
+                        externalVolume.mIsGranted = granted;
+                    } else {
+                        // Directory only.
+                        externalVolume.mChildren.add(new Pair<>(dir, granted));
+                    }
+                }
+            }
+        }
+
+        LOG.v("external volumes: " + externalVolumes);
+
+        if (externalVolumes.isEmpty()) {
+            // We're done!
+            return;
+        }
+
+        // Add entries from external volumes
+
+        // Query StorageManager to get the user-friendly volume names.
+        StorageManager sm = getContext().getSystemService(StorageManager.class);
+        List<VolumeInfo> volumes = sm.getVolumes();
+        if (volumes.isEmpty()) {
+            LOG.w("StorageManager returned no secondary volumes");
+            return;
+        }
+        Map<String, String> volumeNames = new HashMap<>(volumes.size());
+        for (VolumeInfo volume : volumes) {
+            String uuid = volume.getFsUuid();
+            if (uuid == null) {
+                continue; // Primary storage, only directory name used.
+            }
+            String name = sm.getBestVolumeDescription(volume);
+            if (name == null) {
+                LOG.w("No description for " + volume + "; using uuid instead: " + uuid);
+                name = uuid;
+            }
+            volumeNames.put(uuid, name);
+        }
+        LOG.v("UUID -> name mapping: " + volumeNames);
+
+        for (ExternalVolume volume : externalVolumes.values()) {
+            String volumeName = volumeNames.get(volume.mUuid);
+            if (volumeName == null) {
+                LOG.w("Ignoring entry for invalid UUID: " + volume.mUuid);
+                continue;
+            }
+            // First add the preference for the whole volume...
+            preferenceGroup.addPreference(createPreference(volumeName, providerUri, volume.mUuid,
+                    /* dir= */ null, volume.mIsGranted));
+
+            // ... then the child preferences for directories.
+            if (!volume.mIsGranted) {
+                volume.mChildren.forEach(pair -> {
+                    String dir = pair.first;
+                    boolean isGranted = pair.second;
+                    String name = getContext().getResources()
+                            .getString(R.string.directory_on_volume, volumeName, dir);
+                    SwitchPreference childPref =
+                            createPreference(name, providerUri, volume.mUuid, dir, isGranted);
+                    preferenceGroup.addPreference(childPref);
+                });
+            }
+        }
+    }
+
+    private SwitchPreference createPreference(String title, Uri providerUri, String uuid,
+            String dir, boolean isGranted) {
+        SwitchPreference pref = new SwitchPreference(getContext());
+        pref.setKey(String.format("%s:%s", uuid, dir));
+        pref.setTitle(title);
+        pref.setChecked(isGranted);
+        pref.setPersistent(false);
+        pref.setOnPreferenceChangeListener((unused, value) -> {
+            boolean newGrantedState = (Boolean) value;
+            setGranted(newGrantedState, providerUri, uuid, dir);
+            refreshUi();
+            return true;
+        });
+        return pref;
+    }
+
+    private void setGranted(boolean isGranted, Uri providerUri,
+            @Nullable String uuid, @Nullable String directory) {
+        LOG.d("Asking " + providerUri + " to update " + uuid + "/" + directory + " to "
+                + isGranted);
+        ContentValues values = new ContentValues(1);
+        values.put(COL_GRANTED, isGranted);
+        int updated = getContext().getContentResolver().update(providerUri, values,
+                /* where= */ null, new String[]{mPackageName, uuid, directory});
+        LOG.d("Updated " + updated + " entries for " + uuid + "/" + directory);
+    }
+
+    private static class ExternalVolume {
+
+        String mUuid;
+        /** Key: directory, Value: isGranted */
+        List<Pair<String, Boolean>> mChildren = new ArrayList<>();
+        boolean mIsGranted;
+
+        ExternalVolume(String uuid) {
+            mUuid = uuid;
+        }
+
+        @Override
+        public String toString() {
+            return "ExternalVolume: [uuid=" + mUuid + ", granted=" + mIsGranted + ", children="
+                    + mChildren + "]";
+        }
+    }
+}
diff --git a/tests/robotests/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsPreferenceControllerTest.java b/tests/robotests/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsPreferenceControllerTest.java
new file mode 100644
index 0000000..146c978
--- /dev/null
+++ b/tests/robotests/src/com/android/car/settings/applications/specialaccess/DirectoryAccessDetailsPreferenceControllerTest.java
@@ -0,0 +1,358 @@
+/*
+ * Copyright 2019 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.car.settings.applications.specialaccess;
+
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.AUTHORITY;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.COL_GRANTED;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COL_DIRECTORY;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COL_GRANTED;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COL_PACKAGE;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PERMISSIONS_COL_VOLUME_UUID;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertThrows;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Environment;
+import android.os.storage.StorageManager;
+import android.os.storage.VolumeInfo;
+
+import androidx.lifecycle.Lifecycle;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceGroup;
+import androidx.preference.TwoStatePreference;
+
+import com.android.car.settings.CarSettingsRobolectricTestRunner;
+import com.android.car.settings.common.LogicalPreferenceGroup;
+import com.android.car.settings.common.PreferenceControllerTestHelper;
+import com.android.car.settings.testutils.ShadowStorageManager;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.Shadows;
+import org.robolectric.annotation.Config;
+import org.robolectric.fakes.BaseCursor;
+import org.robolectric.shadow.api.Shadow;
+import org.robolectric.shadows.ShadowContentResolver;
+
+import java.util.Arrays;
+
+/** Unit test for {@link DirectoryAccessDetailsPreferenceController}. */
+@RunWith(CarSettingsRobolectricTestRunner.class)
+@Config(shadows = {ShadowStorageManager.class})
+public class DirectoryAccessDetailsPreferenceControllerTest {
+
+    private static final String PACKAGE = "test.package";
+
+    @Mock
+    private BaseCursor mCursor;
+    private Uri mProviderUri;
+
+    private Context mContext;
+    private PreferenceGroup mPreferenceGroup;
+    private PreferenceControllerTestHelper<DirectoryAccessDetailsPreferenceController>
+            mControllerHelper;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        mContext = RuntimeEnvironment.application;
+        mPreferenceGroup = new LogicalPreferenceGroup(mContext);
+        mControllerHelper = new PreferenceControllerTestHelper<>(mContext,
+                DirectoryAccessDetailsPreferenceController.class);
+        mControllerHelper.getController().setPackage(PACKAGE);
+        mControllerHelper.setPreference(mPreferenceGroup);
+
+        mProviderUri = new Uri.Builder()
+                .scheme(ContentResolver.SCHEME_CONTENT)
+                .authority(AUTHORITY)
+                .appendPath(TABLE_PERMISSIONS)
+                .appendPath("*")
+                .build();
+        getShadowContentResolver().setCursor(mProviderUri, mCursor);
+    }
+
+    @Test
+    public void checkInitialized_noPackageSet_throwsIllegalStateException() {
+        mControllerHelper = new PreferenceControllerTestHelper<>(mContext,
+                DirectoryAccessDetailsPreferenceController.class);
+
+        assertThrows(IllegalStateException.class,
+                () -> mControllerHelper.setPreference(new LogicalPreferenceGroup(mContext)));
+    }
+
+    @Test
+    public void onCreate_primaryStoragePermission_addsPreference() {
+        when(mCursor.getCount()).thenReturn(1);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        // Null uuid for primary storage.
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(null);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(
+                Environment.DIRECTORY_PICTURES);
+
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void onCreate_primaryStoragePermission_granted_setsChecked() {
+        when(mCursor.getCount()).thenReturn(1);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        // Null uuid for primary storage.
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(null);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(
+                Environment.DIRECTORY_PICTURES);
+        when(mCursor.getInt(TABLE_PERMISSIONS_COL_GRANTED)).thenReturn(1);
+
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+
+        TwoStatePreference pref = (TwoStatePreference) mPreferenceGroup.getPreference(0);
+        assertThat(pref.isChecked()).isTrue();
+    }
+
+    @Test
+    public void onCreate_primaryStoragePermission_setsPreferenceTitleToDirectory() {
+        String dirName = Environment.DIRECTORY_PICTURES;
+        when(mCursor.getCount()).thenReturn(1);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        // Null uuid for primary storage.
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(null);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(dirName);
+
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+
+        Preference pref = mPreferenceGroup.getPreference(0);
+        assertThat(pref.getTitle()).isEqualTo(dirName);
+    }
+
+    @Test
+    public void onCreate_externalVolumePermission_wholeVolume_addsPreference() {
+        String name = "external volume";
+        String uuid = "external uuid";
+        VolumeInfo primaryStorage = mock(VolumeInfo.class);
+        VolumeInfo external = mock(VolumeInfo.class);
+        when(external.getFsUuid()).thenReturn(uuid);
+        getShadowStorageManager().setVolumes(Arrays.asList(primaryStorage, external));
+        getShadowStorageManager().setBestVolumeDescription(external, name);
+
+        when(mCursor.getCount()).thenReturn(1);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(uuid);
+        // Null directory indicates access for whole volume.
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(null);
+
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(1);
+        assertThat(mPreferenceGroup.getPreference(0).getTitle()).isEqualTo(name);
+    }
+
+    @Test
+    public void onCreate_externalVolumePermission_directory_addsWholeAndDirectoryPreferences() {
+        String name = "external volume";
+        String uuid = "external uuid";
+        String dirName = Environment.DIRECTORY_PICTURES;
+        VolumeInfo primaryStorage = mock(VolumeInfo.class);
+        VolumeInfo external = mock(VolumeInfo.class);
+        when(external.getFsUuid()).thenReturn(uuid);
+        getShadowStorageManager().setVolumes(Arrays.asList(primaryStorage, external));
+        getShadowStorageManager().setBestVolumeDescription(external, name);
+
+        when(mCursor.getCount()).thenReturn(2);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(uuid);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(null).thenReturn(
+                dirName);
+        // Root not granted.
+        when(mCursor.getInt(TABLE_PERMISSIONS_COL_GRANTED)).thenReturn(0);
+
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(2);
+        assertThat(mPreferenceGroup.getPreference(0).getTitle()).isEqualTo(name);
+        // External volume directory preference should have name of volume and directory.
+        assertThat(mPreferenceGroup.getPreference(1).getTitle().toString()).contains(name);
+        assertThat(mPreferenceGroup.getPreference(1).getTitle().toString()).contains(dirName);
+    }
+
+    @Test
+    public void onCreate_externalVolumePermission_rootGranted_hidesDirectoryPreference() {
+        String name = "external volume";
+        String uuid = "external uuid";
+        String dirName = Environment.DIRECTORY_PICTURES;
+        VolumeInfo primaryStorage = mock(VolumeInfo.class);
+        VolumeInfo external = mock(VolumeInfo.class);
+        when(external.getFsUuid()).thenReturn(uuid);
+        getShadowStorageManager().setVolumes(Arrays.asList(primaryStorage, external));
+        getShadowStorageManager().setBestVolumeDescription(external, name);
+
+        when(mCursor.getCount()).thenReturn(2);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(uuid);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(null).thenReturn(
+                dirName);
+        // Root granted.
+        when(mCursor.getInt(TABLE_PERMISSIONS_COL_GRANTED)).thenReturn(1);
+
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(1);
+        assertThat(mPreferenceGroup.getPreference(0).getTitle()).isEqualTo(name);
+    }
+
+    @Test
+    public void onPreferenceClicked_primaryStorage_updatesContentProvider() {
+        String dirName = Environment.DIRECTORY_PICTURES;
+        when(mCursor.getCount()).thenReturn(1);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        // Null uuid for primary storage.
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(null);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(dirName);
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+        Preference pref = mPreferenceGroup.getPreference(0);
+
+        pref.performClick();
+
+        assertThat(getShadowContentResolver().getUpdateStatements()).hasSize(1);
+        ShadowContentResolver.UpdateStatement updateStatement =
+                getShadowContentResolver().getUpdateStatements().get(0);
+        assertThat(updateStatement.getUri()).isEqualTo(mProviderUri);
+        assertThat(updateStatement.getContentValues().get(COL_GRANTED)).isEqualTo(true);
+        assertThat(updateStatement.getSelectionArgs()).isEqualTo(
+                new String[]{PACKAGE, null, dirName});
+    }
+
+    @Test
+    public void onPreferenceClicked_externalStorage_updatesContentProvider() {
+        String uuid = "external uuid";
+        VolumeInfo primaryStorage = mock(VolumeInfo.class);
+        VolumeInfo external = mock(VolumeInfo.class);
+        when(external.getFsUuid()).thenReturn(uuid);
+        getShadowStorageManager().setVolumes(Arrays.asList(primaryStorage, external));
+        getShadowStorageManager().setBestVolumeDescription(external, "external volume");
+
+        when(mCursor.getCount()).thenReturn(1);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(uuid);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(null);
+        // Root granted
+        when(mCursor.getInt(TABLE_PERMISSIONS_COL_GRANTED)).thenReturn(1);
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+        Preference pref = mPreferenceGroup.getPreference(0);
+
+        pref.performClick();
+
+        assertThat(getShadowContentResolver().getUpdateStatements()).hasSize(1);
+        ShadowContentResolver.UpdateStatement updateStatement =
+                getShadowContentResolver().getUpdateStatements().get(0);
+        assertThat(updateStatement.getUri()).isEqualTo(mProviderUri);
+        assertThat(updateStatement.getContentValues().get(COL_GRANTED)).isEqualTo(false);
+        assertThat(updateStatement.getSelectionArgs()).isEqualTo(
+                new String[]{PACKAGE, uuid, null});
+    }
+
+    @Test
+    public void onPreferenceClicked_externalStorage_directory_updatesContentProvider() {
+        String uuid = "external uuid";
+        String dirName = Environment.DIRECTORY_PICTURES;
+        VolumeInfo primaryStorage = mock(VolumeInfo.class);
+        VolumeInfo external = mock(VolumeInfo.class);
+        when(external.getFsUuid()).thenReturn(uuid);
+        getShadowStorageManager().setVolumes(Arrays.asList(primaryStorage, external));
+        getShadowStorageManager().setBestVolumeDescription(external, "external volume");
+
+        when(mCursor.getCount()).thenReturn(2);
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(uuid);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(null).thenReturn(
+                dirName);
+        // Root not granted.
+        when(mCursor.getInt(TABLE_PERMISSIONS_COL_GRANTED)).thenReturn(0);
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+        Preference pref = mPreferenceGroup.getPreference(1);
+
+        pref.performClick();
+
+        assertThat(getShadowContentResolver().getUpdateStatements()).hasSize(1);
+        ShadowContentResolver.UpdateStatement updateStatement =
+                getShadowContentResolver().getUpdateStatements().get(0);
+        assertThat(updateStatement.getUri()).isEqualTo(mProviderUri);
+        assertThat(updateStatement.getContentValues().get(COL_GRANTED)).isEqualTo(true);
+        assertThat(updateStatement.getSelectionArgs()).isEqualTo(
+                new String[]{PACKAGE, uuid, dirName});
+    }
+
+    @Test
+    public void onPreferenceClicked_refreshesUi() {
+        String uuid = "external uuid";
+        String dirName = Environment.DIRECTORY_PICTURES;
+        VolumeInfo primaryStorage = mock(VolumeInfo.class);
+        VolumeInfo external = mock(VolumeInfo.class);
+        when(external.getFsUuid()).thenReturn(uuid);
+        getShadowStorageManager().setVolumes(Arrays.asList(primaryStorage, external));
+        getShadowStorageManager().setBestVolumeDescription(external, "external volume");
+
+        when(mCursor.getCount()).thenReturn(2);
+        // Setup for two iterations over cursor with two rows.
+        when(mCursor.moveToNext()).thenReturn(true).thenReturn(true).thenReturn(false).thenReturn(
+                true).thenReturn(true).thenReturn(false);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_PACKAGE)).thenReturn(PACKAGE);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID)).thenReturn(uuid);
+        when(mCursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY)).thenReturn(null).thenReturn(
+                dirName).thenReturn(null).thenReturn(dirName);
+        // Root not granted, dir not granted -> root granted, dir not explicitly granted.
+        when(mCursor.getInt(TABLE_PERMISSIONS_COL_GRANTED)).thenReturn(0).thenReturn(0).thenReturn(
+                1).thenReturn(0);
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_CREATE);
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(2);
+        Preference pref = mPreferenceGroup.getPreference(0);
+
+        pref.performClick();
+
+        // Granting access to root should hide the directory preference on refresh.
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(1);
+    }
+
+    private ShadowContentResolver getShadowContentResolver() {
+        return Shadows.shadowOf(mContext.getContentResolver());
+    }
+
+    private ShadowStorageManager getShadowStorageManager() {
+        return Shadow.extract(mContext.getSystemService(StorageManager.class));
+    }
+}
diff --git a/tests/robotests/src/com/android/car/settings/testutils/ShadowStorageManager.java b/tests/robotests/src/com/android/car/settings/testutils/ShadowStorageManager.java
new file mode 100644
index 0000000..0fc2bb4
--- /dev/null
+++ b/tests/robotests/src/com/android/car/settings/testutils/ShadowStorageManager.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2019 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.car.settings.testutils;
+
+import android.os.storage.StorageManager;
+import android.os.storage.VolumeInfo;
+import android.util.ArrayMap;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+import java.util.List;
+import java.util.Map;
+
+@Implements(StorageManager.class)
+public class ShadowStorageManager {
+
+    private List<VolumeInfo> mVolumes;
+    private Map<VolumeInfo, String> mBestVolumeDescriptions = new ArrayMap<>();
+
+    public void setVolumes(List<VolumeInfo> volumes) {
+        mVolumes = volumes;
+    }
+
+    @Implementation
+    protected List<VolumeInfo> getVolumes() {
+        return mVolumes;
+    }
+
+    public void setBestVolumeDescription(VolumeInfo volume, String description) {
+        mBestVolumeDescriptions.put(volume, description);
+    }
+
+    @Implementation
+    protected String getBestVolumeDescription(VolumeInfo volume) {
+        return mBestVolumeDescriptions.get(volume);
+    }
+}