Home | History | Annotate | Download | only in documentsui
      1 /*
      2  * Copyright (C) 2016 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.documentsui;
     18 
     19 import static android.os.Environment.isStandardDirectory;
     20 import static android.os.Environment.STANDARD_DIRECTORIES;
     21 import static android.os.storage.StorageVolume.EXTRA_DIRECTORY_NAME;
     22 import static android.os.storage.StorageVolume.EXTRA_STORAGE_VOLUME;
     23 
     24 import static com.android.documentsui.LocalPreferences.getScopedAccessPermissionStatus;
     25 import static com.android.documentsui.LocalPreferences.PERMISSION_ASK;
     26 import static com.android.documentsui.LocalPreferences.PERMISSION_ASK_AGAIN;
     27 import static com.android.documentsui.LocalPreferences.PERMISSION_NEVER_ASK;
     28 import static com.android.documentsui.LocalPreferences.setScopedAccessPermissionStatus;
     29 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_ALREADY_DENIED;
     30 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_ALREADY_GRANTED;
     31 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_DENIED;
     32 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_DENIED_AND_PERSIST;
     33 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_ERROR;
     34 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_GRANTED;
     35 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_INVALID_ARGUMENTS;
     36 import static com.android.documentsui.Metrics.SCOPED_DIRECTORY_ACCESS_INVALID_DIRECTORY;
     37 import static com.android.documentsui.Metrics.logInvalidScopedAccessRequest;
     38 import static com.android.documentsui.Metrics.logValidScopedAccessRequest;
     39 import static com.android.documentsui.Shared.DEBUG;
     40 
     41 import android.annotation.SuppressLint;
     42 import android.app.Activity;
     43 import android.app.ActivityManager;
     44 import android.app.AlertDialog;
     45 import android.app.Dialog;
     46 import android.app.DialogFragment;
     47 import android.app.FragmentManager;
     48 import android.app.FragmentTransaction;
     49 import android.content.ContentProviderClient;
     50 import android.content.Context;
     51 import android.content.DialogInterface;
     52 import android.content.DialogInterface.OnClickListener;
     53 import android.content.Intent;
     54 import android.content.UriPermission;
     55 import android.content.pm.PackageManager;
     56 import android.content.pm.PackageManager.NameNotFoundException;
     57 import android.net.Uri;
     58 import android.os.Bundle;
     59 import android.os.Parcelable;
     60 import android.os.RemoteException;
     61 import android.os.UserHandle;
     62 import android.os.storage.StorageManager;
     63 import android.os.storage.StorageVolume;
     64 import android.os.storage.VolumeInfo;
     65 import android.provider.DocumentsContract;
     66 import android.text.TextUtils;
     67 import android.util.Log;
     68 import android.view.View;
     69 import android.widget.CheckBox;
     70 import android.widget.CompoundButton;
     71 import android.widget.CompoundButton.OnCheckedChangeListener;
     72 import android.widget.TextView;
     73 
     74 import java.io.File;
     75 import java.io.IOException;
     76 import java.util.List;
     77 
     78 /**
     79  * Activity responsible for handling {@link Intent#ACTION_OPEN_EXTERNAL_DOCUMENT}.
     80  */
     81 public class OpenExternalDirectoryActivity extends Activity {
     82     private static final String TAG = "OpenExternalDirectory";
     83     private static final String FM_TAG = "open_external_directory";
     84     private static final String EXTERNAL_STORAGE_AUTH = "com.android.externalstorage.documents";
     85     private static final String EXTRA_FILE = "com.android.documentsui.FILE";
     86     private static final String EXTRA_APP_LABEL = "com.android.documentsui.APP_LABEL";
     87     private static final String EXTRA_VOLUME_LABEL = "com.android.documentsui.VOLUME_LABEL";
     88     private static final String EXTRA_VOLUME_UUID = "com.android.documentsui.VOLUME_UUID";
     89     private static final String EXTRA_IS_ROOT = "com.android.documentsui.IS_ROOT";
     90     private static final String EXTRA_IS_PRIMARY = "com.android.documentsui.IS_PRIMARY";
     91     // Special directory name representing the full volume
     92     static final String DIRECTORY_ROOT = "ROOT_DIRECTORY";
     93 
     94     private ContentProviderClient mExternalStorageClient;
     95 
     96     @Override
     97     public void onCreate(Bundle savedInstanceState) {
     98         super.onCreate(savedInstanceState);
     99         if (savedInstanceState != null) {
    100             if (DEBUG) Log.d(TAG, "activity.onCreateDialog(): reusing instance");
    101             return;
    102         }
    103 
    104         final Intent intent = getIntent();
    105         if (intent == null) {
    106             if (DEBUG) Log.d(TAG, "missing intent");
    107             logInvalidScopedAccessRequest(this, SCOPED_DIRECTORY_ACCESS_INVALID_ARGUMENTS);
    108             setResult(RESULT_CANCELED);
    109             finish();
    110             return;
    111         }
    112         final Parcelable storageVolume = intent.getParcelableExtra(EXTRA_STORAGE_VOLUME);
    113         if (!(storageVolume instanceof StorageVolume)) {
    114             if (DEBUG)
    115                 Log.d(TAG, "extra " + EXTRA_STORAGE_VOLUME + " is not a StorageVolume: "
    116                         + storageVolume);
    117             logInvalidScopedAccessRequest(this, SCOPED_DIRECTORY_ACCESS_INVALID_ARGUMENTS);
    118             setResult(RESULT_CANCELED);
    119             finish();
    120             return;
    121         }
    122         String directoryName = intent.getStringExtra(EXTRA_DIRECTORY_NAME );
    123         if (directoryName == null) {
    124             directoryName = DIRECTORY_ROOT;
    125         }
    126         final StorageVolume volume = (StorageVolume) storageVolume;
    127         if (getScopedAccessPermissionStatus(getApplicationContext(), getCallingPackage(),
    128                 volume.getUuid(), directoryName) == PERMISSION_NEVER_ASK) {
    129             logValidScopedAccessRequest(this, directoryName,
    130                     SCOPED_DIRECTORY_ACCESS_ALREADY_DENIED);
    131             setResult(RESULT_CANCELED);
    132             finish();
    133             return;
    134         }
    135 
    136         final int userId = UserHandle.myUserId();
    137         if (!showFragment(this, userId, volume, directoryName)) {
    138             setResult(RESULT_CANCELED);
    139             finish();
    140             return;
    141         }
    142     }
    143 
    144     @Override
    145     public void onDestroy() {
    146         super.onDestroy();
    147         if (mExternalStorageClient != null) {
    148             mExternalStorageClient.close();
    149         }
    150     }
    151 
    152     /**
    153      * Validates the given path (volume + directory) and display the appropriate dialog asking the
    154      * user to grant access to it.
    155      */
    156     private static boolean showFragment(OpenExternalDirectoryActivity activity, int userId,
    157             StorageVolume storageVolume, String directoryName) {
    158         if (DEBUG)
    159             Log.d(TAG, "showFragment() for volume " + storageVolume.dump() + ", directory "
    160                     + directoryName + ", and user " + userId);
    161         final boolean isRoot = directoryName.equals(DIRECTORY_ROOT);
    162         final boolean isPrimary = storageVolume.isPrimary();
    163 
    164         if (isRoot && isPrimary) {
    165             if (DEBUG) Log.d(TAG, "root access requested on primary volume");
    166             return false;
    167         }
    168 
    169         final File volumeRoot = storageVolume.getPathFile();
    170         File file;
    171         try {
    172             file = isRoot ? volumeRoot : new File(volumeRoot, directoryName).getCanonicalFile();
    173         } catch (IOException e) {
    174             Log.e(TAG, "Could not get canonical file for volume " + storageVolume.dump()
    175                     + " and directory " + directoryName);
    176             logInvalidScopedAccessRequest(activity, SCOPED_DIRECTORY_ACCESS_ERROR);
    177             return false;
    178         }
    179         final StorageManager sm =
    180                 (StorageManager) activity.getSystemService(Context.STORAGE_SERVICE);
    181 
    182         final String root, directory;
    183         if (isRoot) {
    184             root = volumeRoot.getAbsolutePath();
    185             directory = ".";
    186         } else {
    187             root = file.getParent();
    188             directory = file.getName();
    189             // Verify directory is valid.
    190             if (TextUtils.isEmpty(directory) || !isStandardDirectory(directory)) {
    191                 if (DEBUG)
    192                     Log.d(TAG, "Directory '" + directory + "' is not standard (full path: '"
    193                             + file.getAbsolutePath() + "')");
    194                 logInvalidScopedAccessRequest(activity, SCOPED_DIRECTORY_ACCESS_INVALID_DIRECTORY);
    195                 return false;
    196             }
    197         }
    198 
    199         // Gets volume label and converted path.
    200         String volumeLabel = null;
    201         String volumeUuid = null;
    202         final List<VolumeInfo> volumes = sm.getVolumes();
    203         if (DEBUG) Log.d(TAG, "Number of volumes: " + volumes.size());
    204         File internalRoot = null;
    205         boolean found = true;
    206         for (VolumeInfo volume : volumes) {
    207             if (isRightVolume(volume, root, userId)) {
    208                 found = true;
    209                 internalRoot = volume.getInternalPathForUser(userId);
    210                 // Must convert path before calling getDocIdForFileCreateNewDir()
    211                 if (DEBUG) Log.d(TAG, "Converting " + root + " to " + internalRoot);
    212                 file = isRoot ? internalRoot : new File(internalRoot, directory);
    213                 volumeUuid = storageVolume.getUuid();
    214                 volumeLabel = sm.getBestVolumeDescription(volume);
    215                 if (TextUtils.isEmpty(volumeLabel)) {
    216                     volumeLabel = storageVolume.getDescription(activity);
    217                 }
    218                 if (TextUtils.isEmpty(volumeLabel)) {
    219                     volumeLabel = activity.getString(android.R.string.unknownName);
    220                     Log.w(TAG, "No volume description  for " + volume + "; using " + volumeLabel);
    221                 }
    222                 break;
    223             }
    224         }
    225         if (internalRoot == null) {
    226             // Should not happen on normal circumstances, unless app crafted an invalid volume
    227             // using reflection or the list of mounted volumes changed.
    228             Log.e(TAG, "Didn't find right volume for '" + storageVolume.dump() + "' on " + volumes);
    229             return false;
    230         }
    231 
    232         // Checks if the user has granted the permission already.
    233         final Intent intent = getIntentForExistingPermission(activity, isRoot, internalRoot, file);
    234         if (intent != null) {
    235             logValidScopedAccessRequest(activity, directory,
    236                     SCOPED_DIRECTORY_ACCESS_ALREADY_GRANTED);
    237             activity.setResult(RESULT_OK, intent);
    238             activity.finish();
    239             return true;
    240         }
    241 
    242         if (!found) {
    243             Log.e(TAG, "Could not get volume for " + file);
    244             logInvalidScopedAccessRequest(activity, SCOPED_DIRECTORY_ACCESS_ERROR);
    245             return false;
    246         }
    247 
    248         // Gets the package label.
    249         final String appLabel = getAppLabel(activity);
    250         if (appLabel == null) {
    251             // Error already logged.
    252             return false;
    253         }
    254 
    255         // Sets args that will be retrieve on onCreate()
    256         final Bundle args = new Bundle();
    257         args.putString(EXTRA_FILE, file.getAbsolutePath());
    258         args.putString(EXTRA_VOLUME_LABEL, volumeLabel);
    259         args.putString(EXTRA_VOLUME_UUID, volumeUuid);
    260         args.putString(EXTRA_APP_LABEL, appLabel);
    261         args.putBoolean(EXTRA_IS_ROOT, isRoot);
    262         args.putBoolean(EXTRA_IS_PRIMARY, isPrimary);
    263 
    264         final FragmentManager fm = activity.getFragmentManager();
    265         final FragmentTransaction ft = fm.beginTransaction();
    266         final OpenExternalDirectoryDialogFragment fragment =
    267                 new OpenExternalDirectoryDialogFragment();
    268         fragment.setArguments(args);
    269         ft.add(fragment, FM_TAG);
    270         ft.commitAllowingStateLoss();
    271 
    272         return true;
    273     }
    274 
    275     private static String getAppLabel(Activity activity) {
    276         final String packageName = activity.getCallingPackage();
    277         final PackageManager pm = activity.getPackageManager();
    278         try {
    279             return pm.getApplicationLabel(pm.getApplicationInfo(packageName, 0)).toString();
    280         } catch (NameNotFoundException e) {
    281             logInvalidScopedAccessRequest(activity, SCOPED_DIRECTORY_ACCESS_ERROR);
    282             Log.w(TAG, "Could not get label for package " + packageName);
    283             return null;
    284         }
    285     }
    286 
    287     private static boolean isRightVolume(VolumeInfo volume, String root, int userId) {
    288         final File userPath = volume.getPathForUser(userId);
    289         final String path = userPath == null ? null : volume.getPathForUser(userId).getPath();
    290         final boolean isMounted = volume.isMountedReadable();
    291         if (DEBUG)
    292             Log.d(TAG, "Volume: " + volume
    293                     + "\n\tuserId: " + userId
    294                     + "\n\tuserPath: " + userPath
    295                     + "\n\troot: " + root
    296                     + "\n\tpath: " + path
    297                     + "\n\tisMounted: " + isMounted);
    298 
    299         return isMounted && root.equals(path);
    300     }
    301 
    302     private static Uri getGrantedUriPermission(Context context, ContentProviderClient provider,
    303             File file) {
    304         // Calls ExternalStorageProvider to get the doc id for the file
    305         final Bundle bundle;
    306         try {
    307             bundle = provider.call("getDocIdForFileCreateNewDir", file.getPath(), null);
    308         } catch (RemoteException e) {
    309             Log.e(TAG, "Did not get doc id from External Storage provider for " + file, e);
    310             logInvalidScopedAccessRequest(context, SCOPED_DIRECTORY_ACCESS_ERROR);
    311             return null;
    312         }
    313         final String docId = bundle == null ? null : bundle.getString("DOC_ID");
    314         if (docId == null) {
    315             Log.e(TAG, "Did not get doc id from External Storage provider for " + file);
    316             logInvalidScopedAccessRequest(context, SCOPED_DIRECTORY_ACCESS_ERROR);
    317             return null;
    318         }
    319         if (DEBUG) Log.d(TAG, "doc id for " + file + ": " + docId);
    320 
    321         final Uri uri = DocumentsContract.buildTreeDocumentUri(EXTERNAL_STORAGE_AUTH, docId);
    322         if (uri == null) {
    323             Log.e(TAG, "Could not get URI for doc id " + docId);
    324             return null;
    325         }
    326         if (DEBUG) Log.d(TAG, "URI for " + file + ": " + uri);
    327         return uri;
    328     }
    329 
    330     private static Intent createGrantedUriPermissionsIntent(Context context,
    331             ContentProviderClient provider, File file) {
    332         final Uri uri = getGrantedUriPermission(context, provider, file);
    333         return createGrantedUriPermissionsIntent(uri);
    334     }
    335 
    336     private static Intent createGrantedUriPermissionsIntent(Uri uri) {
    337         final Intent intent = new Intent();
    338         intent.setData(uri);
    339         intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
    340                 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
    341                 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
    342                 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
    343         return intent;
    344     }
    345 
    346     private static Intent getIntentForExistingPermission(OpenExternalDirectoryActivity activity,
    347             boolean isRoot, File root, File file) {
    348         final String packageName = activity.getCallingPackage();
    349         final ContentProviderClient storageClient = activity.getExternalStorageClient();
    350         final Uri grantedUri = getGrantedUriPermission(activity, storageClient, file);
    351         final Uri rootUri = root.equals(file) ? grantedUri
    352                 : getGrantedUriPermission(activity, storageClient, root);
    353 
    354         if (DEBUG)
    355             Log.d(TAG, "checking if " + packageName + " already has permission for " + grantedUri
    356                     + " or its root (" + rootUri + ")");
    357         final ActivityManager am =
    358                 (ActivityManager) activity.getSystemService(Context.ACTIVITY_SERVICE);
    359         for (UriPermission uriPermission : am.getGrantedUriPermissions(packageName).getList()) {
    360             final Uri uri = uriPermission.getUri();
    361             if (uri == null) {
    362                 Log.w(TAG, "null URI for " + uriPermission);
    363                 continue;
    364             }
    365             if (uri.equals(grantedUri) || uri.equals(rootUri)) {
    366                 if (DEBUG) Log.d(TAG, packageName + " already has permission: " + uriPermission);
    367                 return createGrantedUriPermissionsIntent(grantedUri);
    368             }
    369         }
    370         if (DEBUG) Log.d(TAG, packageName + " does not have permission for " + grantedUri);
    371         return null;
    372     }
    373 
    374     public static class OpenExternalDirectoryDialogFragment extends DialogFragment {
    375 
    376         private File mFile;
    377         private String mVolumeUuid;
    378         private String mVolumeLabel;
    379         private String mAppLabel;
    380         private boolean mIsRoot;
    381         private boolean mIsPrimary;
    382         private CheckBox mDontAskAgain;
    383         private OpenExternalDirectoryActivity mActivity;
    384         private AlertDialog mDialog;
    385 
    386         @Override
    387         public void onCreate(Bundle savedInstanceState) {
    388             super.onCreate(savedInstanceState);
    389             setRetainInstance(true);
    390             final Bundle args = getArguments();
    391             if (args != null) {
    392                 mFile = new File(args.getString(EXTRA_FILE));
    393                 mVolumeUuid = args.getString(EXTRA_VOLUME_UUID);
    394                 mVolumeLabel = args.getString(EXTRA_VOLUME_LABEL);
    395                 mAppLabel = args.getString(EXTRA_APP_LABEL);
    396                 mIsRoot = args.getBoolean(EXTRA_IS_ROOT);
    397                 mIsPrimary= args.getBoolean(EXTRA_IS_PRIMARY);
    398             }
    399             mActivity = (OpenExternalDirectoryActivity) getActivity();
    400         }
    401 
    402         @Override
    403         public void onDestroyView() {
    404             // Workaround for https://code.google.com/p/android/issues/detail?id=17423
    405             if (mDialog != null && getRetainInstance()) {
    406                 mDialog.setDismissMessage(null);
    407             }
    408             super.onDestroyView();
    409         }
    410 
    411         @Override
    412         public Dialog onCreateDialog(Bundle savedInstanceState) {
    413             if (mDialog != null) {
    414                 if (DEBUG) Log.d(TAG, "fragment.onCreateDialog(): reusing dialog");
    415                 return mDialog;
    416             }
    417             if (mActivity != getActivity()) {
    418                 // Sanity check.
    419                 Log.wtf(TAG, "activity references don't match on onCreateDialog(): mActivity = "
    420                         + mActivity + " , getActivity() = " + getActivity());
    421                 mActivity = (OpenExternalDirectoryActivity) getActivity();
    422             }
    423             final String directory = mFile.getName();
    424             final String directoryName = mIsRoot ? DIRECTORY_ROOT : directory;
    425             final Context context = mActivity.getApplicationContext();
    426             final OnClickListener listener = new OnClickListener() {
    427 
    428                 @Override
    429                 public void onClick(DialogInterface dialog, int which) {
    430                     Intent intent = null;
    431                     if (which == DialogInterface.BUTTON_POSITIVE) {
    432                         intent = createGrantedUriPermissionsIntent(mActivity,
    433                                 mActivity.getExternalStorageClient(), mFile);
    434                     }
    435                     if (which == DialogInterface.BUTTON_NEGATIVE || intent == null) {
    436                         logValidScopedAccessRequest(mActivity, directoryName,
    437                                 SCOPED_DIRECTORY_ACCESS_DENIED);
    438                         final boolean checked = mDontAskAgain.isChecked();
    439                         if (checked) {
    440                             logValidScopedAccessRequest(mActivity, directory,
    441                                     SCOPED_DIRECTORY_ACCESS_DENIED_AND_PERSIST);
    442                             setScopedAccessPermissionStatus(context, mActivity.getCallingPackage(),
    443                                     mVolumeUuid, directoryName, PERMISSION_NEVER_ASK);
    444                         } else {
    445                             setScopedAccessPermissionStatus(context, mActivity.getCallingPackage(),
    446                                     mVolumeUuid, directoryName, PERMISSION_ASK_AGAIN);
    447                         }
    448                         mActivity.setResult(RESULT_CANCELED);
    449                     } else {
    450                         logValidScopedAccessRequest(mActivity, directory,
    451                                 SCOPED_DIRECTORY_ACCESS_GRANTED);
    452                         mActivity.setResult(RESULT_OK, intent);
    453                     }
    454                     mActivity.finish();
    455                 }
    456             };
    457 
    458             @SuppressLint("InflateParams")
    459             // It's ok pass null ViewRoot on AlertDialogs.
    460             final View view = View.inflate(mActivity, R.layout.dialog_open_scoped_directory, null);
    461             final CharSequence message;
    462             if (mIsRoot) {
    463                 message = TextUtils.expandTemplate(getText(
    464                         R.string.open_external_dialog_root_request), mAppLabel, mVolumeLabel);
    465             } else {
    466                 message = TextUtils.expandTemplate(
    467                         getText(mIsPrimary ? R.string.open_external_dialog_request_primary_volume
    468                                 : R.string.open_external_dialog_request),
    469                                 mAppLabel, directory, mVolumeLabel);
    470             }
    471             final TextView messageField = (TextView) view.findViewById(R.id.message);
    472             messageField.setText(message);
    473             mDialog = new AlertDialog.Builder(mActivity, R.style.Theme_AppCompat_Light_Dialog_Alert)
    474                     .setView(view)
    475                     .setPositiveButton(R.string.allow, listener)
    476                     .setNegativeButton(R.string.deny, listener)
    477                     .create();
    478 
    479             mDontAskAgain = (CheckBox) view.findViewById(R.id.do_not_ask_checkbox);
    480             if (getScopedAccessPermissionStatus(context, mActivity.getCallingPackage(),
    481                     mVolumeUuid, directoryName) == PERMISSION_ASK_AGAIN) {
    482                 mDontAskAgain.setVisibility(View.VISIBLE);
    483                 mDontAskAgain.setOnCheckedChangeListener(new OnCheckedChangeListener() {
    484 
    485                     @Override
    486                     public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
    487                         mDialog.getButton(DialogInterface.BUTTON_POSITIVE).setEnabled(!isChecked);
    488                     }
    489                 });
    490             }
    491 
    492             return mDialog;
    493         }
    494 
    495         @Override
    496         public void onCancel(DialogInterface dialog) {
    497             super.onCancel(dialog);
    498             final Activity activity = getActivity();
    499             logValidScopedAccessRequest(activity, mFile.getName(), SCOPED_DIRECTORY_ACCESS_DENIED);
    500             activity.setResult(RESULT_CANCELED);
    501             activity.finish();
    502         }
    503     }
    504 
    505     private synchronized ContentProviderClient getExternalStorageClient() {
    506         if (mExternalStorageClient == null) {
    507             mExternalStorageClient =
    508                     getContentResolver().acquireContentProviderClient(EXTERNAL_STORAGE_AUTH);
    509         }
    510         return mExternalStorageClient;
    511     }
    512 }
    513