blob: 787d21eeb1af2a450d814c79627763903c7b4526 [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.documentsui;
import static android.os.storage.StorageVolume.ScopedAccessProviderContract.COL_GRANTED;
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 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 android.os.Environment.isStandardDirectory;
import static com.android.documentsui.base.SharedMinimal.DEBUG;
import static com.android.documentsui.base.SharedMinimal.getExternalDirectoryName;
import static com.android.documentsui.base.SharedMinimal.getInternalDirectoryName;
import static com.android.documentsui.base.SharedMinimal.getUriPermission;
import static com.android.documentsui.prefs.ScopedAccessLocalPreferences.PERMISSION_ASK_AGAIN;
import static com.android.documentsui.prefs.ScopedAccessLocalPreferences.PERMISSION_GRANTED;
import static com.android.documentsui.prefs.ScopedAccessLocalPreferences.PERMISSION_NEVER_ASK;
import static com.android.documentsui.prefs.ScopedAccessLocalPreferences.clearScopedAccessPreferences;
import static com.android.documentsui.prefs.ScopedAccessLocalPreferences.getAllPackages;
import static com.android.documentsui.prefs.ScopedAccessLocalPreferences.getAllPermissions;
import static com.android.documentsui.prefs.ScopedAccessLocalPreferences.setScopedAccessPermissionStatus;
import static com.android.internal.util.Preconditions.checkArgument;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.GrantedUriPermission;
import android.content.ContentProvider;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Environment;
import android.os.UserHandle;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.provider.DocumentsContract;
import android.util.ArraySet;
import android.util.Log;
import com.android.documentsui.base.Providers;
import com.android.documentsui.prefs.ScopedAccessLocalPreferences.Permission;
import com.android.internal.util.ArrayUtils;
import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
//TODO(b/72055774): update javadoc once implementation is finished
/**
* Provider used to manage scoped access directory permissions.
*
* <p>It fetches data from 2 sources:
*
* <ul>
* <li>{@link com.android.documentsui.prefs.ScopedAccessLocalPreferences} for denied permissions.
* <li>{@link ActivityManager} for allowed permissions.
* </ul>
*
* <p>And returns the results in 2 tables:
*
* <ul>
* <li>{@link #TABLE_PACKAGES}: read-only table with the name of all packages
* (column ({@link android.os.storage.StorageVolume.ScopedAccessProviderContract#COL_PACKAGE}) that
* had a scoped access directory permission granted or denied.
* <li>{@link #TABLE_PERMISSIONS}: writable table with the name of all packages
* (column ({@link android.os.storage.StorageVolume.ScopedAccessProviderContract#COL_PACKAGE}) that
* had a scoped access directory
* (column ({@link android.os.storage.StorageVolume.ScopedAccessProviderContract#COL_DIRECTORY})
* permission for a volume (column
* {@link android.os.storage.StorageVolume.ScopedAccessProviderContract#COL_VOLUME_UUID}, which
* contains the volume UUID or {@code null} if it's the primary partition) granted or denied
* (column ({@link android.os.storage.StorageVolume.ScopedAccessProviderContract#COL_GRANTED}).
* </ul>
*
* <p><b>Note:</b> the {@code query()} methods return all entries; it does not support selection or
* projections.
*/
// TODO(b/72055774): add unit tests
public class ScopedAccessProvider extends ContentProvider {
private static final String TAG = "ScopedAccessProvider";
private static final UriMatcher sMatcher = new UriMatcher(UriMatcher.NO_MATCH);
private static final int URI_PACKAGES = 1;
private static final int URI_PERMISSIONS = 2;
public static final String AUTHORITY = "com.android.documentsui.scopedAccess";
static {
sMatcher.addURI(AUTHORITY, TABLE_PACKAGES + "/*", URI_PACKAGES);
sMatcher.addURI(AUTHORITY, TABLE_PERMISSIONS + "/*", URI_PERMISSIONS);
}
@Override
public boolean onCreate() {
return true;
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
if (DEBUG) {
Log.v(TAG, "query(" + uri + "): proj=" + Arrays.toString(projection)
+ ", sel=" + selection);
}
switch (sMatcher.match(uri)) {
case URI_PACKAGES:
return getPackagesCursor();
case URI_PERMISSIONS:
if (ArrayUtils.isEmpty(selectionArgs)) {
throw new UnsupportedOperationException("selections cannot be empty");
}
// For simplicity, we only support one package (which is what Settings is passing).
if (selectionArgs.length > 1) {
Log.w(TAG, "Using just first entry of " + Arrays.toString(selectionArgs));
}
return getPermissionsCursor(selectionArgs[0]);
default:
throw new UnsupportedOperationException("Unsupported Uri " + uri);
}
}
private Cursor getPackagesCursor() {
final Context context = getContext();
// First, get the packages that were denied
final Set<String> pkgs = getAllPackages(context);
// Second, query AM to get all packages that have a permission.
final ActivityManager am =
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
final List<GrantedUriPermission> amPkgs = am.getGrantedUriPermissions(null).getList();
if (!amPkgs.isEmpty()) {
amPkgs.forEach((perm) -> pkgs.add(perm.packageName));
}
if (ArrayUtils.isEmpty(pkgs)) {
if (DEBUG) Log.v(TAG, "getPackagesCursor(): nothing to do" );
return null;
}
if (DEBUG) {
Log.v(TAG, "getPackagesCursor(): denied=" + pkgs + ", granted=" + amPkgs);
}
// Finally, create the cursor
final MatrixCursor cursor = new MatrixCursor(TABLE_PACKAGES_COLUMNS, pkgs.size());
pkgs.forEach((pkg) -> cursor.addRow( new Object[] { pkg }));
return cursor;
}
// TODO(b/72055774): need to unit tests to handle scenarios where the root permission of
// a secondary volume mismatches a child permission (for example, child is allowed by root
// is denied).
private Cursor getPermissionsCursor(String packageName) {
final Context context = getContext();
// List of volumes that were granted by AM at the root level - in that case,
// we can ignored individual grants from AM or denials from our preferences
final Set<String> grantedVolumes = new ArraySet<>();
// List of directories (mapped by volume uuid) that were granted by AM so they can be
// ignored if also found on our preferences
final Map<String, Set<String>> grantedDirsByUuid = new HashMap<>();
// Cursor rows
final List<Object[]> permissions = new ArrayList<>();
// First, query AM to get all packages that have a permission.
final ActivityManager am =
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
final List<GrantedUriPermission> uriPermissions =
am.getGrantedUriPermissions(packageName).getList();
if (DEBUG) {
Log.v(TAG, "am returned =" + uriPermissions);
}
setGrantedPermissions(packageName, uriPermissions, permissions, grantedVolumes,
grantedDirsByUuid);
// Now gets the packages that were denied
final List<Permission> rawPermissions = getAllPermissions(context);
if (DEBUG) {
Log.v(TAG, "rawPermissions: " + rawPermissions);
}
// Merge the permissions granted by AM with the denied permissions saved on our preferences.
for (Permission rawPermission : rawPermissions) {
if (!packageName.equals(rawPermission.pkg)) {
if (DEBUG) {
Log.v(TAG,
"ignoring " + rawPermission + " because package is not " + packageName);
}
continue;
}
if (rawPermission.status != PERMISSION_NEVER_ASK
&& rawPermission.status != PERMISSION_ASK_AGAIN) {
// We only care for status where the user denied a request.
if (DEBUG) {
Log.v(TAG, "ignoring " + rawPermission + " because of its status");
}
continue;
}
if (grantedVolumes.contains(rawPermission.uuid)) {
if (DEBUG) {
Log.v(TAG, "ignoring " + rawPermission + " because whole volume is granted");
}
continue;
}
final Set<String> grantedDirs = grantedDirsByUuid.get(rawPermission.uuid);
if (grantedDirs != null
&& grantedDirs.contains(rawPermission.directory)) {
Log.w(TAG, "ignoring " + rawPermission + " because it was granted already");
continue;
}
permissions.add(new Object[] {
packageName, rawPermission.uuid,
getExternalDirectoryName(rawPermission.directory), 0
});
}
if (DEBUG) {
Log.v(TAG, "total permissions: " + permissions.size());
}
// Then create the cursor
final MatrixCursor cursor = new MatrixCursor(TABLE_PERMISSIONS_COLUMNS, permissions.size());
permissions.forEach((row) -> cursor.addRow(row));
return cursor;
}
/**
* Converts the permissions returned by AM and add it to 3 buckets ({@code permissions},
* {@code grantedVolumes}, and {@code grantedDirsByUuid}).
*
* @param packageName name of package that the permissions were granted to.
* @param uriPermissions permissions returend by AM
* @param permissions list of permissions that can be converted to a {@link #TABLE_PERMISSIONS}
* row.
* @param grantedVolumes volume uuids that were granted full access.
* @param grantedDirsByUuid directories that were granted individual acces (key is volume uuid,
* value is list of directories).
*/
private void setGrantedPermissions(String packageName, List<GrantedUriPermission> uriPermissions,
List<Object[]> permissions, Set<String> grantedVolumes,
Map<String, Set<String>> grantedDirsByUuid) {
final List<Permission> grantedPermissions = parseGrantedPermissions(uriPermissions);
for (Permission p : grantedPermissions) {
// First check if it's for the full volume
if (p.directory == null) {
if (p.uuid == null) {
// Should never happen - the Scoped Directory Access API does not allow it.
Log.w(TAG, "ignoring entry whose uuid and directory is null");
continue;
}
grantedVolumes.add(p.uuid);
} else {
if (!ArrayUtils.contains(Environment.STANDARD_DIRECTORIES, p.directory)) {
if (DEBUG) Log.v(TAG, "Ignoring non-standard directory on " + p);
continue;
}
Set<String> dirs = grantedDirsByUuid.get(p.uuid);
if (dirs == null) {
// Life would be so much easier if Android had MultiMaps...
dirs = new HashSet<>(1);
grantedDirsByUuid.put(p.uuid, dirs);
}
dirs.add(p.directory);
}
}
if (DEBUG) {
Log.v(TAG, "grantedVolumes=" + grantedVolumes
+ ", grantedDirectories=" + grantedDirsByUuid);
}
// Add granted permissions to full volumes.
grantedVolumes.forEach((uuid) -> permissions.add(new Object[] {
packageName, uuid, /* dir= */ null, 1
}));
// Add granted permissions to individual directories
grantedDirsByUuid.forEach((uuid, dirs) -> {
if (grantedVolumes.contains(uuid)) {
Log.w(TAG, "Ignoring individual grants to " + uuid + ": " + dirs);
} else {
dirs.forEach((dir) -> permissions.add(new Object[] {packageName, uuid, dir, 1}));
}
});
}
/**
* Converts the permissions returned by AM to our own format.
*/
private List<Permission> parseGrantedPermissions(List<GrantedUriPermission> uriPermissions) {
final List<Permission> permissions = new ArrayList<>(uriPermissions.size());
// TODO(b/72055774): we should query AUTHORITY_STORAGE or call DocumentsContract instead of
// hardcoding the logic here.
for (GrantedUriPermission uriPermission : uriPermissions) {
final Uri uri = uriPermission.uri;
final String authority = uri.getAuthority();
if (!Providers.AUTHORITY_STORAGE.equals(authority)) {
Log.w(TAG, "Wrong authority on " + uri);
continue;
}
final List<String> pathSegments = uri.getPathSegments();
if (pathSegments.size() < 2) {
Log.w(TAG, "wrong path segments on " + uri);
continue;
}
// TODO(b/72055774): make PATH_TREE private again if not used anymore
if (!DocumentsContract.PATH_TREE.equals(pathSegments.get(0))) {
Log.w(TAG, "wrong path tree on " + uri);
continue;
}
final String[] uuidAndDir = pathSegments.get(1).split(":");
// uuid and dir are either UUID:DIR (for scoped directory) or UUID: (for full volume)
if (uuidAndDir.length != 1 && uuidAndDir.length != 2) {
Log.w(TAG, "could not parse uuid and directory on " + uri);
continue;
}
// TODO(b/72055774): to make things uglier, the Documents directory in the primary
// storage is a special case as its URI is "$ROOT_ID_HOME", instead of
// "${ROOT_ID_DEVICE}/Documents. This is another reason to move this logic to the
// provider...
final String uuid, dir;
if (Providers.ROOT_ID_HOME.equals(uuidAndDir[0])) {
uuid = null;
dir = Environment.DIRECTORY_DOCUMENTS;
} else {
uuid = Providers.ROOT_ID_DEVICE.equals(uuidAndDir[0])
? null // primary
: uuidAndDir[0]; // external volume
dir = uuidAndDir.length == 1 ? null : uuidAndDir[1];
}
permissions
.add(new Permission(uriPermission.packageName, uuid, dir, PERMISSION_GRANTED));
}
return permissions;
}
@Override
public String getType(Uri uri) {
return null;
}
@Override
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException("insert(): unsupported " + uri);
}
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
if (sMatcher.match(uri) != URI_PERMISSIONS) {
throw new UnsupportedOperationException("delete(): unsupported " + uri);
}
if (DEBUG) {
Log.v(TAG, "delete(" + uri + "): " + Arrays.toString(selectionArgs));
}
// TODO(b/72055774): add unit tests for invalid input
checkArgument(selectionArgs != null && selectionArgs.length == 1,
"Must have exactly 1 args: package_name" + Arrays.toString(selectionArgs));
final String packageName = selectionArgs[0];
// Delete just our preferences - the URI permissions is handled externally
// TODO(b/72055774): move logic to revoke permissions here, so AppStorageSettings does
// not need to call am.clearGrantedUriPermissions(packageName) (then we could remove that
// method from ActivityManager)
return clearScopedAccessPreferences(getContext(), packageName);
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
if (sMatcher.match(uri) != URI_PERMISSIONS) {
throw new UnsupportedOperationException("update(): unsupported " + uri);
}
if (DEBUG) {
Log.v(TAG, "update(" + uri + "): " + Arrays.toString(selectionArgs) + " = " + values);
}
// TODO(b/72055774): add unit tests for invalid input
checkArgument(selectionArgs != null && selectionArgs.length == 3,
"Must have exactly 3 args: package_name, (nullable) uuid, (nullable) directory: "
+ Arrays.toString(selectionArgs));
final String packageName = selectionArgs[0];
final String uuid = selectionArgs[1];
final String dir = selectionArgs[2];
final boolean granted = values.getAsBoolean(COL_GRANTED);
// First update the effective URI permission ...
if (!persistUriPermission(packageName, uuid, dir, granted)) {
// Failed - nothing left to do...
return 0;
}
// ...then our preferences.
setScopedAccessPermissionStatus(getContext(), packageName, uuid,
getInternalDirectoryName(dir), granted ? PERMISSION_GRANTED : PERMISSION_NEVER_ASK);
return 1;
}
/**
* Calls AM to persist a URI.
*
* @return whether the call succeeded.
*/
private boolean persistUriPermission(String packageName, @Nullable String uuid,
@Nullable String directory, boolean granted) {
final Context context = getContext();
final ContentProviderClient storageClient = context.getContentResolver()
.acquireContentProviderClient(Providers.AUTHORITY_STORAGE);
final StorageManager sm = context.getSystemService(StorageManager.class);
StorageVolume volume = null;
if (uuid == null) {
if (directory == null) {
Log.w(TAG, "cannot grant full access to the primary volume");
return false;
}
volume = sm.getPrimaryStorageVolume();
} else {
for (StorageVolume candidate : sm.getVolumeList()) {
if (uuid.equals(candidate.getUuid())) {
volume = candidate;
break;
}
}
if (volume == null) {
Log.w(TAG, "didn't find volume for UUID=" + uuid);
return false;
}
if (directory != null && !isStandardDirectory(directory)) {
Log.w(TAG, "not a scoped directory: " + directory);
return false;
}
}
return getUriPermission(context, storageClient, volume, getInternalDirectoryName(directory),
UserHandle.getCallingUserId(), /* logMetrics= */ false,
(file, volumeLabel, isRoot, isPrimary, grantedUri, rootUri) -> {
updatePermission(context, grantedUri, packageName, granted);
return true;
});
}
private void updatePermission(Context context, Uri grantedUri, String toPackage,
boolean granted) {
final int persistFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION;
final int grantFlags = persistFlags
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
final ContentResolver cr = context.getContentResolver();
if (granted) {
context.grantUriPermission(toPackage, grantedUri, grantFlags);
cr.takePersistableUriPermission(toPackage, grantedUri, persistFlags);
} else {
context.revokeUriPermission(toPackage, grantedUri, grantFlags);
// There's no need to release after revoking
}
}
@Override
public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
final String prefix = " ";
final List<String> packages = new ArrayList<>();
pw.print("Packages: ");
try (Cursor cursor = getPackagesCursor()) {
if (cursor == null || cursor.getCount() == 0) {
pw.println("N/A");
} else {
pw.println(cursor.getCount());
while (cursor.moveToNext()) {
final String pkg = cursor.getString(TABLE_PACKAGES_COL_PACKAGE);
packages.add(pkg);
pw.print(prefix);
pw.println(pkg);
}
}
}
pw.print("Permissions: ");
for (int i = 0; i < packages.size(); i++) {
final String pkg = packages.get(i);
try (Cursor cursor = getPermissionsCursor(pkg)) {
if (cursor == null) {
pw.println("N/A");
} else {
pw.println(cursor.getCount());
while (cursor.moveToNext()) {
pw.print(prefix); pw.print(cursor.getString(TABLE_PERMISSIONS_COL_PACKAGE));
pw.print('/');
final String uuid = cursor.getString(TABLE_PERMISSIONS_COL_VOLUME_UUID);
if (uuid != null) {
pw.print(uuid); pw.print('>');
}
pw.print(cursor.getString(TABLE_PERMISSIONS_COL_DIRECTORY));
pw.print(": "); pw.println(cursor.getInt(TABLE_PERMISSIONS_COL_GRANTED) == 1);
}
}
}
}
pw.print("Raw permissions: ");
final List<Permission> rawPermissions = getAllPermissions(getContext());
if (rawPermissions.isEmpty()) {
pw.println("N/A");
} else {
final int size = rawPermissions.size();
pw.println(size);
for (int i = 0; i < size; i++) {
final Permission permission = rawPermissions.get(i);
pw.print(prefix); pw.println(permission);
}
}
}
}