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