DO NOT MERGE Add directory access to special app access.

This setting allows resetting the state of the "Do not ask again" state of the dialog which prompts a user to allow or deny an app to access a directory in external storage.

Bug: 122824071

Test: build and deploy, deny access permanently on sample app, toggle access, test with requesting external media dirs, RunCarSettingsRoboTests
Change-Id: Ia890ce53354878f275d88c1c080bb2eb504044e8
diff --git a/res/values/preference_keys.xml b/res/values/preference_keys.xml
index 822a49c..fd6b2a5 100644
--- a/res/values/preference_keys.xml
+++ b/res/values/preference_keys.xml
@@ -191,6 +191,8 @@
     <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_entry" translatable="false">directory_access_entry</string>
+    <string name="pk_directory_access" translatable="false">directory_access</string>
     <string name="pk_directory_access_details" translatable="false">directory_access_details
     </string>
     <string name="pk_directory_access_details_app" translatable="false">
diff --git a/res/xml/directory_access_fragment.xml b/res/xml/directory_access_fragment.xml
new file mode 100644
index 0000000..ff5d2cb
--- /dev/null
+++ b/res/xml/directory_access_fragment.xml
@@ -0,0 +1,25 @@
+<?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">
+    <com.android.car.settings.common.LogicalPreferenceGroup
+        android:key="@string/pk_directory_access"
+        settings:controller="com.android.car.settings.applications.specialaccess.DirectoryAccessPreferenceController"/>
+</PreferenceScreen>
diff --git a/res/xml/special_access_fragment.xml b/res/xml/special_access_fragment.xml
index a9abce1..ecae227 100644
--- a/res/xml/special_access_fragment.xml
+++ b/res/xml/special_access_fragment.xml
@@ -40,6 +40,11 @@
         android:title="@string/usage_access_title"
         settings:controller="com.android.car.settings.common.DefaultRestrictionsPreferenceController"/>
     <Preference
+        android:fragment="com.android.car.settings.applications.specialaccess.DirectoryAccessFragment"
+        android:key="@string/pk_directory_access_entry"
+        android:title="@string/directory_access_title"
+        settings:controller="com.android.car.settings.common.DefaultRestrictionsPreferenceController"/>
+    <Preference
         android:fragment="com.android.car.settings.applications.specialaccess.WifiControlFragment"
         android:key="@string/pk_wifi_control_entry"
         android:title="@string/wifi_control_title"
diff --git a/src/com/android/car/settings/applications/specialaccess/DirectoryAccessFragment.java b/src/com/android/car/settings/applications/specialaccess/DirectoryAccessFragment.java
new file mode 100644
index 0000000..cb6394b
--- /dev/null
+++ b/src/com/android/car/settings/applications/specialaccess/DirectoryAccessFragment.java
@@ -0,0 +1,32 @@
+/*
+ * 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 androidx.annotation.XmlRes;
+
+import com.android.car.settings.R;
+import com.android.car.settings.common.SettingsFragment;
+
+/** Displays the list of applications which have been denied external storage directory access. */
+public class DirectoryAccessFragment extends SettingsFragment {
+
+    @Override
+    @XmlRes
+    protected int getPreferenceScreenResId() {
+        return R.xml.directory_access_fragment;
+    }
+}
diff --git a/src/com/android/car/settings/applications/specialaccess/DirectoryAccessPreferenceController.java b/src/com/android/car/settings/applications/specialaccess/DirectoryAccessPreferenceController.java
new file mode 100644
index 0000000..60c9a22
--- /dev/null
+++ b/src/com/android/car/settings/applications/specialaccess/DirectoryAccessPreferenceController.java
@@ -0,0 +1,162 @@
+/*
+ * 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.TABLE_PACKAGES;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PACKAGES_COLUMNS;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PACKAGES_COL_PACKAGE;
+
+import android.car.drivingstate.CarUxRestrictions;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.util.ArraySet;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceGroup;
+
+import com.android.car.settings.common.FragmentController;
+import com.android.car.settings.common.Logger;
+import com.android.car.settings.common.PreferenceController;
+import com.android.settingslib.applications.ApplicationsState.AppEntry;
+import com.android.settingslib.applications.ApplicationsState.AppFilter;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Displays a list of preferences for apps that have directory access permissions set. Selecting an
+ * app launches a detailed view for controlling permissions at the directory level.
+ */
+public class DirectoryAccessPreferenceController extends PreferenceController<PreferenceGroup> {
+
+    private static final Logger LOG = new Logger(DirectoryAccessPreferenceController.class);
+
+    private static final AppFilter FILTER_APP_HAS_DIRECTORY_ACCESS = new AppFilter() {
+
+        private Set<String> mPackages;
+
+        @Override
+        public void init() {
+            throw new UnsupportedOperationException("Need to call constructor that takes context");
+        }
+
+        @Override
+        public void init(Context context) {
+            mPackages = null;
+            Uri providerUri = new Uri.Builder()
+                    .scheme(ContentResolver.SCHEME_CONTENT)
+                    .authority(AUTHORITY)
+                    .appendPath(TABLE_PACKAGES)
+                    .appendPath("*")
+                    .build();
+            try (Cursor cursor = context.getContentResolver().query(providerUri,
+                    TABLE_PACKAGES_COLUMNS, /* queryArgs= */ null, /* cancellationSignal= */
+                    null)) {
+                if (cursor == null) {
+                    LOG.w("Didn't get cursor for " + providerUri);
+                    return;
+                }
+                int count = cursor.getCount();
+                if (count == 0) {
+                    LOG.d("No packages anymore (was " + mPackages + ")");
+                    return;
+                }
+                mPackages = new ArraySet<>(count);
+                while (cursor.moveToNext()) {
+                    mPackages.add(cursor.getString(TABLE_PACKAGES_COL_PACKAGE));
+                }
+                LOG.d("init(): " + mPackages);
+            }
+        }
+
+
+        @Override
+        public boolean filterApp(AppEntry info) {
+            return mPackages != null && mPackages.contains(info.info.packageName);
+        }
+    };
+
+    private final AppEntryListManager.Callback mCallback = new AppEntryListManager.Callback() {
+        @Override
+        public void onAppEntryListChanged(List<AppEntry> entries) {
+            mEntries = entries;
+            refreshUi();
+        }
+    };
+
+    @VisibleForTesting
+    AppEntryListManager mAppEntryListManager;
+    private List<AppEntry> mEntries;
+
+    public DirectoryAccessPreferenceController(Context context, String preferenceKey,
+            FragmentController fragmentController, CarUxRestrictions uxRestrictions) {
+        super(context, preferenceKey, fragmentController, uxRestrictions);
+        mAppEntryListManager = new AppEntryListManager(context);
+    }
+
+    @Override
+    protected Class<PreferenceGroup> getPreferenceType() {
+        return PreferenceGroup.class;
+    }
+
+    @Override
+    protected void onCreateInternal() {
+        mAppEntryListManager.init(/* extraInfoBridge= */ null,
+                () -> FILTER_APP_HAS_DIRECTORY_ACCESS, mCallback);
+    }
+
+    @Override
+    protected void onStartInternal() {
+        mAppEntryListManager.start();
+    }
+
+    @Override
+    protected void onStopInternal() {
+        mAppEntryListManager.stop();
+    }
+
+    @Override
+    protected void onDestroyInternal() {
+        mAppEntryListManager.destroy();
+    }
+
+    @Override
+    protected void updateState(PreferenceGroup preference) {
+        if (mEntries == null) {
+            // Still loading.
+            return;
+        }
+        preference.removeAll();
+        for (AppEntry entry : mEntries) {
+            Preference appPreference = new Preference(getContext());
+            String key = entry.info.packageName + "|" + entry.info.uid;
+            appPreference.setKey(key);
+            appPreference.setTitle(entry.label);
+            appPreference.setIcon(entry.icon);
+            appPreference.setOnPreferenceClickListener(clickedPref -> {
+                getFragmentController().launchFragment(
+                        DirectoryAccessDetailsFragment.getInstance(entry.info.packageName));
+                return true;
+            });
+            preference.addPreference(appPreference);
+        }
+    }
+}
diff --git a/tests/robotests/src/com/android/car/settings/applications/specialaccess/DirectoryAccessPreferenceControllerTest.java b/tests/robotests/src/com/android/car/settings/applications/specialaccess/DirectoryAccessPreferenceControllerTest.java
new file mode 100644
index 0000000..3da0a44
--- /dev/null
+++ b/tests/robotests/src/com/android/car/settings/applications/specialaccess/DirectoryAccessPreferenceControllerTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.TABLE_PACKAGES;
+import static android.os.storage.StorageVolume.ScopedAccessProviderContract.TABLE_PACKAGES_COL_PACKAGE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.net.Uri;
+import android.os.Looper;
+
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.Lifecycle;
+import androidx.preference.Preference;
+import androidx.preference.PreferenceGroup;
+
+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.ShadowApplicationsState;
+import com.android.car.settings.testutils.ShadowContentResolver;
+import com.android.settingslib.applications.ApplicationsState;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+import org.robolectric.fakes.BaseCursor;
+import org.robolectric.shadow.api.Shadow;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/** Unit test for {@link DirectoryAccessPreferenceController}. */
+@RunWith(CarSettingsRobolectricTestRunner.class)
+@Config(shadows = {ShadowApplicationsState.class, ShadowContentResolver.class})
+public class DirectoryAccessPreferenceControllerTest {
+
+    @Mock
+    private AppEntryListManager mAppEntryListManager;
+    @Mock
+    private ApplicationsState mApplicationsState;
+    @Captor
+    private ArgumentCaptor<AppEntryListManager.AppFilterProvider> mFilterCaptor;
+    @Captor
+    private ArgumentCaptor<AppEntryListManager.Callback> mCallbackCaptor;
+
+    private Context mContext;
+    private PreferenceGroup mPreferenceGroup;
+    private PreferenceControllerTestHelper<DirectoryAccessPreferenceController> mControllerHelper;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        ShadowApplicationsState.setInstance(mApplicationsState);
+        when(mApplicationsState.getBackgroundLooper()).thenReturn(Looper.getMainLooper());
+
+        mContext = RuntimeEnvironment.application;
+        mPreferenceGroup = new LogicalPreferenceGroup(mContext);
+        mControllerHelper = new PreferenceControllerTestHelper<>(mContext,
+                DirectoryAccessPreferenceController.class, mPreferenceGroup);
+        mControllerHelper.getController().mAppEntryListManager = mAppEntryListManager;
+        mControllerHelper.markState(Lifecycle.State.CREATED);
+        verify(mAppEntryListManager).init(isNull(), mFilterCaptor.capture(),
+                mCallbackCaptor.capture());
+    }
+
+    @After
+    public void tearDown() {
+        ShadowApplicationsState.reset();
+        ShadowContentResolver.reset();
+    }
+
+    @Test
+    public void onStart_startsListManager() {
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_START);
+
+        verify(mAppEntryListManager).start();
+    }
+
+    @Test
+    public void onStop_stopsListManager() {
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_STOP);
+
+        verify(mAppEntryListManager).stop();
+    }
+
+    @Test
+    public void onDestroy_destroysListManager() {
+        mControllerHelper.sendLifecycleEvent(Lifecycle.Event.ON_DESTROY);
+
+        verify(mAppEntryListManager).destroy();
+    }
+
+    @Test
+    public void onAppEntryListChanged_addsPreferencesForEntries() {
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        List<ApplicationsState.AppEntry> entries = Arrays.asList(
+                createAppEntry("test.package", /* uid= */ 1),
+                createAppEntry("another.test.package", /* uid= */ 2));
+
+        mCallbackCaptor.getValue().onAppEntryListChanged(entries);
+
+        assertThat(mPreferenceGroup.getPreferenceCount()).isEqualTo(2);
+    }
+
+    @Test
+    public void onPreferenceClicked_launchesDetailsFragmentForPackage() {
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        String packageName = "test.package";
+        List<ApplicationsState.AppEntry> entries = Collections.singletonList(
+                createAppEntry(packageName, /* uid= */ 1));
+        mCallbackCaptor.getValue().onAppEntryListChanged(entries);
+        Preference appPref = mPreferenceGroup.getPreference(0);
+
+        appPref.performClick();
+
+        ArgumentCaptor<Fragment> fragmentCaptor = ArgumentCaptor.forClass(Fragment.class);
+        verify(mControllerHelper.getMockFragmentController()).launchFragment(
+                fragmentCaptor.capture());
+        assertThat(fragmentCaptor.getValue()).isInstanceOf(DirectoryAccessDetailsFragment.class);
+        assertThat(fragmentCaptor.getValue().getArguments().getString(
+                DirectoryAccessDetailsFragment.ARG_PACKAGE_NAME)).isEqualTo(packageName);
+    }
+
+    @Test
+    public void appFilter_removesPackagesNotInScopedAccessProvider() {
+        mControllerHelper.markState(Lifecycle.State.STARTED);
+        String includedPackage = "test.package";
+        String excludedPackage = "test.package2";
+
+        BaseCursor cursor = mock(BaseCursor.class);
+        when(cursor.getCount()).thenReturn(1);
+        when(cursor.moveToNext()).thenReturn(true).thenReturn(false);
+        when(cursor.getString(TABLE_PACKAGES_COL_PACKAGE)).thenReturn(includedPackage);
+
+        Uri providerUri = new Uri.Builder()
+                .scheme(ContentResolver.SCHEME_CONTENT)
+                .authority(AUTHORITY)
+                .appendPath(TABLE_PACKAGES)
+                .appendPath("*")
+                .build();
+        getShadowContentResolver().setCursor(providerUri, cursor);
+
+        ApplicationsState.AppFilter filter = mFilterCaptor.getValue().getAppFilter();
+        filter.init(mContext);
+
+        assertThat(filter.filterApp(createAppEntry(includedPackage, /* uid= */ 1))).isTrue();
+        assertThat(filter.filterApp(createAppEntry(excludedPackage, /* uid= */ 2))).isFalse();
+    }
+
+    private ApplicationsState.AppEntry createAppEntry(String packageName, int uid) {
+        ApplicationInfo info = new ApplicationInfo();
+        info.packageName = packageName;
+        info.uid = uid;
+
+        ApplicationsState.AppEntry appEntry = mock(ApplicationsState.AppEntry.class);
+        appEntry.info = info;
+        appEntry.label = packageName;
+
+        return appEntry;
+    }
+
+    private ShadowContentResolver getShadowContentResolver() {
+        return Shadow.extract(mContext.getContentResolver());
+    }
+}
diff --git a/tests/robotests/src/com/android/car/settings/testutils/ShadowContentResolver.java b/tests/robotests/src/com/android/car/settings/testutils/ShadowContentResolver.java
index e899705..e9359ec 100644
--- a/tests/robotests/src/com/android/car/settings/testutils/ShadowContentResolver.java
+++ b/tests/robotests/src/com/android/car/settings/testutils/ShadowContentResolver.java
@@ -23,7 +23,10 @@
 import android.content.SyncInfo;
 import android.content.SyncStatusInfo;
 import android.content.SyncStatusObserver;
+import android.database.Cursor;
+import android.net.Uri;
 import android.os.Bundle;
+import android.os.CancellationSignal;
 
 import org.robolectric.annotation.Implementation;
 import org.robolectric.annotation.Implements;
@@ -55,6 +58,13 @@
     private static SyncStatusObserver sStatusObserver;
 
     @Implementation
+    public final Cursor query(Uri uri, String[] projection, Bundle queryArgs,
+            CancellationSignal cancellationSignal) {
+        return query(uri, projection, /* selection= */ null, /* selectionArgs= */
+                null, /* sortOrder= */ null, cancellationSignal);
+    }
+
+    @Implementation
     protected static SyncAdapterType[] getSyncAdapterTypesAsUser(int userId) {
         return sSyncAdapterTypes;
     }