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;
}