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