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