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 onCreate(Bundle savedInstanceState)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 onDestroy()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 */ showFragment(OpenExternalDirectoryActivity activity, int userId, StorageVolume storageVolume, String directoryName)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 getAppLabel(Activity activity)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 isRightVolume(VolumeInfo volume, String root, int userId)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 getGrantedUriPermission(Context context, ContentProviderClient provider, File file)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 createGrantedUriPermissionsIntent(Context context, ContentProviderClient provider, File file)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 createGrantedUriPermissionsIntent(Uri uri)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 getIntentForExistingPermission(OpenExternalDirectoryActivity activity, boolean isRoot, File root, File file)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 onCreate(Bundle savedInstanceState)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 onDestroyView()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 onCreateDialog(Bundle savedInstanceState)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 onCancel(DialogInterface dialog)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 getExternalStorageClient()505 private synchronized ContentProviderClient getExternalStorageClient() { 506 if (mExternalStorageClient == null) { 507 mExternalStorageClient = 508 getContentResolver().acquireContentProviderClient(EXTERNAL_STORAGE_AUTH); 509 } 510 return mExternalStorageClient; 511 } 512 } 513