blob: 8a8fa98b6523c547b7e04c08803581e084209a52 [file] [log] [blame]
/*
* Copyright (C) 2018 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.settings.applications;
import static android.os.storage.StorageVolume.ScopedAccessProviderContract.AUTHORITY;
import static android.os.storage.StorageVolume.ScopedAccessProviderContract.COL_DIRECTORY;
import static android.os.storage.StorageVolume.ScopedAccessProviderContract.COL_GRANTED;
import static android.os.storage.StorageVolume.ScopedAccessProviderContract.COL_PACKAGE;
import static android.os.storage.StorageVolume.ScopedAccessProviderContract.COL_VOLUME_UUID;
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 static com.android.settings.applications.AppStateDirectoryAccessBridge.DEBUG;
import static com.android.settings.applications.AppStateDirectoryAccessBridge.VERBOSE;
import android.annotation.Nullable;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.storage.StorageManager;
import android.os.storage.VolumeInfo;
import androidx.preference.SwitchPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceGroupAdapter;
import androidx.preference.Preference.OnPreferenceChangeListener;
import androidx.preference.Preference.OnPreferenceClickListener;
import androidx.preference.PreferenceCategory;
import android.text.TextUtils;
import androidx.preference.PreferenceManager;
import androidx.preference.PreferenceScreen;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.IconDrawableFactory;
import android.util.Log;
import android.util.Pair;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.settings.R;
import com.android.settings.widget.EntityHeaderController;
import com.android.settings.widget.EntityHeaderController.ActionType;
import com.android.settingslib.applications.AppUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Detailed settings for an app's directory access permissions (A.K.A Scoped Directory Access).
*
* <p>Currently, it shows the entry for which the user denied access with the "Do not ask again"
* flag checked on: the user than can use the settings toggle to reset that deniel.
*
* <p>This fragments dynamically lists all such permissions, starting with one preference per
* directory in the primary storage, then adding additional entries for the external volumes (one
* entry for the whole volume).
*/
// TODO(b/72055774): add unit tests
public class DirectoryAccessDetails extends AppInfoBase {
@SuppressWarnings("hiding")
private static final String TAG = "DirectoryAccessDetails";
private boolean mCreated;
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (mCreated) {
Log.w(TAG, "onActivityCreated(): ignoring duplicate call");
return;
}
mCreated = true;
if (mPackageInfo == null) {
Log.w(TAG, "onActivityCreated(): no package info");
return;
}
final Activity activity = getActivity();
final Preference pref = EntityHeaderController
.newInstance(activity, this, /* header= */ null )
.setRecyclerView(getListView(), getLifecycle())
.setIcon(IconDrawableFactory.newInstance(getPrefContext())
.getBadgedIcon(mPackageInfo.applicationInfo))
.setLabel(mPackageInfo.applicationInfo.loadLabel(mPm))
.setIsInstantApp(AppUtils.isInstant(mPackageInfo.applicationInfo))
.setPackageName(mPackageName)
.setUid(mPackageInfo.applicationInfo.uid)
.setHasAppInfoLink(false)
.setButtonActions(ActionType.ACTION_NONE, ActionType.ACTION_NONE)
.done(activity, getPrefContext());
getPreferenceScreen().addPreference(pref);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
addPreferencesFromResource(R.xml.directory_access_details);
}
@Override
protected boolean refreshUi() {
final Context context = getPrefContext();
final PreferenceScreen prefsGroup = getPreferenceScreen();
prefsGroup.removeAll();
final Map<String, ExternalVolume> externalVolumes = new HashMap<>();
final Uri providerUri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
.authority(AUTHORITY).appendPath(TABLE_PERMISSIONS).appendPath("*")
.build();
// Query provider for entries.
try (Cursor cursor = context.getContentResolver().query(providerUri,
TABLE_PERMISSIONS_COLUMNS, null, new String[] { mPackageName }, null)) {
if (cursor == null) {
Log.w(TAG, "Didn't get cursor for " + mPackageName);
return true;
}
final 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(TAG, "No permissions for " + mPackageName);
return true;
}
while (cursor.moveToNext()) {
final String pkg = cursor.getString(TABLE_PERMISSIONS_COL_PACKAGE);
final String uuid = cursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID);
final String dir = cursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY);
final boolean granted = cursor.getInt(TABLE_PERMISSIONS_COL_GRANTED) == 1;
if (VERBOSE) {
Log.v(TAG, "Pkg:" + pkg + " uuid: " + uuid + " dir: " + dir
+ " granted:" + granted);
}
if (!mPackageName.equals(pkg)) {
// Sanity check, shouldn't happen
Log.w(TAG, "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(TAG, "Ignoring permission on primary storage root");
} else {
// Primary storage entry: add right away
prefsGroup.addPreference(newPreference(context, dir, providerUri,
/* uuid= */ null, dir, granted, /* children= */ null));
}
} 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.granted = granted;
} else {
// Directory only
externalVolume.children.add(new Pair<>(dir, granted));
}
}
}
}
if (VERBOSE) {
Log.v(TAG, "external volumes: " + externalVolumes);
}
if (externalVolumes.isEmpty()) {
// We're done!
return true;
}
// Add entries from external volumes
// Query StorageManager to get the user-friendly volume names.
final StorageManager sm = context.getSystemService(StorageManager.class);
final List<VolumeInfo> volumes = sm.getVolumes();
if (volumes.isEmpty()) {
Log.w(TAG, "StorageManager returned no secondary volumes");
return true;
}
final Map<String, String> volumeNames = new HashMap<>(volumes.size());
for (VolumeInfo volume : volumes) {
final String uuid = volume.getFsUuid();
if (uuid == null) continue; // Primary storage; not used.
String name = sm.getBestVolumeDescription(volume);
if (name == null) {
Log.w(TAG, "No description for " + volume + "; using uuid instead: " + uuid);
name = uuid;
}
volumeNames.put(uuid, name);
}
if (VERBOSE) {
Log.v(TAG, "UUID -> name mapping: " + volumeNames);
}
for (ExternalVolume volume : externalVolumes.values()) {
final String volumeName = volumeNames.get(volume.uuid);
if (volumeName == null) {
Log.w(TAG, "Ignoring entry for invalid UUID: " + volume.uuid);
continue;
}
// First add the pref for the whole volume...
final PreferenceCategory category = new PreferenceCategory(context);
prefsGroup.addPreference(category);
final Set<SwitchPreference> children = new HashSet<>(volume.children.size());
category.addPreference(newPreference(context, volumeName, providerUri, volume.uuid,
/* dir= */ null, volume.granted, children));
// ... then the children prefs
volume.children.forEach((pair) -> {
final String dir = pair.first;
final String name = context.getResources()
.getString(R.string.directory_on_volume, volumeName, dir);
final SwitchPreference childPref =
newPreference(context, name, providerUri, volume.uuid, dir, pair.second,
/* children= */ null);
category.addPreference(childPref);
children.add(childPref);
});
}
return true;
}
private SwitchPreference newPreference(Context context, String title, Uri providerUri,
String uuid, String dir, boolean granted, @Nullable Set<SwitchPreference> children) {
final SwitchPreference pref = new SwitchPreference(context);
pref.setKey(String.format("%s:%s", uuid, dir));
pref.setTitle(title);
pref.setChecked(granted);
pref.setOnPreferenceChangeListener((unused, value) -> {
if (!Boolean.class.isInstance(value)) {
// Sanity check
Log.wtf(TAG, "Invalid value from switch: " + value);
return true;
}
final boolean newValue = ((Boolean) value).booleanValue();
resetDoNotAskAgain(context, newValue, providerUri, uuid, dir);
if (children != null) {
// When parent is granted, children should be hidden; and vice versa
final boolean newChildValue = !newValue;
for (SwitchPreference child : children) {
child.setVisible(newChildValue);
}
}
return true;
});
return pref;
}
private void resetDoNotAskAgain(Context context, boolean newValue, Uri providerUri,
@Nullable String uuid, @Nullable String directory) {
if (DEBUG) {
Log.d(TAG, "Asking " + providerUri + " to update " + uuid + "/" + directory + " to "
+ newValue);
}
final ContentValues values = new ContentValues(1);
values.put(COL_GRANTED, newValue);
final int updated = context.getContentResolver().update(providerUri, values,
null, new String[] { mPackageName, uuid, directory });
if (DEBUG) {
Log.d(TAG, "Updated " + updated + " entries for " + uuid + "/" + directory);
}
}
@Override
protected AlertDialog createDialog(int id, int errorCode) {
return null;
}
@Override
public int getMetricsCategory() {
return MetricsEvent.APPLICATIONS_DIRECTORY_ACCESS_DETAIL;
}
private static class ExternalVolume {
final String uuid;
final List<Pair<String, Boolean>> children = new ArrayList<>();
boolean granted;
ExternalVolume(String uuid) {
this.uuid = uuid;
}
@Override
public String toString() {
return "ExternalVolume: [uuid=" + uuid + ", granted=" + granted +
", children=" + children + "]";
}
}
}