/* * 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. * *

It fetches data from 2 sources: * *

* *

And returns the results in 2 tables: * *

* *

Note: 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 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 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 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> grantedDirsByUuid = new HashMap<>(); // Cursor rows final List 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 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 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 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 uriPermissions, List permissions, Set grantedVolumes, Map> grantedDirsByUuid) { final List 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 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 parseGrantedPermissions(List uriPermissions) { final List 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 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(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 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 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); } } } }