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 package com.android.documentsui.picker; 17 18 import static android.provider.DocumentsContract.isDocumentUri; 19 import static android.provider.DocumentsContract.isRootUri; 20 21 import static com.android.documentsui.base.SharedMinimal.DEBUG; 22 import static com.android.documentsui.base.State.ACTION_CREATE; 23 import static com.android.documentsui.base.State.ACTION_GET_CONTENT; 24 import static com.android.documentsui.base.State.ACTION_OPEN; 25 import static com.android.documentsui.base.State.ACTION_OPEN_TREE; 26 import static com.android.documentsui.base.State.ACTION_PICK_COPY_DESTINATION; 27 28 import static java.util.regex.Pattern.CASE_INSENSITIVE; 29 30 import android.content.ActivityNotFoundException; 31 import android.content.ClipData; 32 import android.content.ComponentName; 33 import android.content.Intent; 34 import android.content.pm.ResolveInfo; 35 import android.net.Uri; 36 import android.os.AsyncTask; 37 import android.os.Parcelable; 38 import android.provider.DocumentsContract; 39 import android.provider.Settings; 40 import android.util.Log; 41 42 import androidx.annotation.NonNull; 43 import androidx.annotation.Nullable; 44 import androidx.annotation.VisibleForTesting; 45 import androidx.fragment.app.FragmentActivity; 46 import androidx.fragment.app.FragmentManager; 47 import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails; 48 49 import com.android.documentsui.AbstractActionHandler; 50 import com.android.documentsui.ActivityConfig; 51 import com.android.documentsui.DocumentsAccess; 52 import com.android.documentsui.Injector; 53 import com.android.documentsui.MetricConsts; 54 import com.android.documentsui.Metrics; 55 import com.android.documentsui.base.BooleanConsumer; 56 import com.android.documentsui.base.DocumentInfo; 57 import com.android.documentsui.base.DocumentStack; 58 import com.android.documentsui.base.Features; 59 import com.android.documentsui.base.Lookup; 60 import com.android.documentsui.base.Providers; 61 import com.android.documentsui.base.RootInfo; 62 import com.android.documentsui.base.Shared; 63 import com.android.documentsui.base.State; 64 import com.android.documentsui.base.UserId; 65 import com.android.documentsui.dirlist.AnimationView; 66 import com.android.documentsui.picker.ActionHandler.Addons; 67 import com.android.documentsui.queries.SearchViewManager; 68 import com.android.documentsui.roots.ProvidersAccess; 69 import com.android.documentsui.services.FileOperationService; 70 import com.android.documentsui.util.FileUtils; 71 72 import java.io.IOException; 73 import java.util.Arrays; 74 import java.util.concurrent.Executor; 75 import java.util.regex.Pattern; 76 77 /** 78 * Provides {@link PickActivity} action specializations to fragments. 79 */ 80 class ActionHandler<T extends FragmentActivity & Addons> extends AbstractActionHandler<T> { 81 82 private static final String TAG = "PickerActionHandler"; 83 84 /** 85 * Used to prevent applications from using {@link Intent#ACTION_OPEN_DOCUMENT_TREE} and 86 * the {@link Intent#ACTION_OPEN_DOCUMENT} actions to request that the user select individual 87 * files from "/Android/data", "/Android/obb", "/Android/sandbox" directories and all their 88 * subdirectories (on the external storage), in accordance with the SAF privacy restrictions 89 * introduced in Android 11 (R). 90 * 91 * <p> 92 * See <a href="https://developer.android.com/about/versions/11/privacy/storage#file-access"> 93 * Storage updates in Android 11</a>. 94 */ 95 private static final Pattern PATTERN_RESTRICTED_INITIAL_PATH = 96 Pattern.compile("^/Android/(?:data|obb|sandbox).*", CASE_INSENSITIVE); 97 98 private final Features mFeatures; 99 private final ActivityConfig mConfig; 100 private final LastAccessedStorage mLastAccessed; 101 private UpdatePickResultTask mUpdatePickResultTask; 102 ActionHandler( T activity, State state, ProvidersAccess providers, DocumentsAccess docs, SearchViewManager searchMgr, Lookup<String, Executor> executors, Injector injector, LastAccessedStorage lastAccessed)103 ActionHandler( 104 T activity, 105 State state, 106 ProvidersAccess providers, 107 DocumentsAccess docs, 108 SearchViewManager searchMgr, 109 Lookup<String, Executor> executors, 110 Injector injector, 111 LastAccessedStorage lastAccessed) { 112 super(activity, state, providers, docs, searchMgr, executors, injector); 113 114 mConfig = injector.config; 115 mFeatures = injector.features; 116 mLastAccessed = lastAccessed; 117 mUpdatePickResultTask = new UpdatePickResultTask( 118 activity.getApplicationContext(), mInjector.pickResult); 119 } 120 121 @Override initLocation(Intent intent)122 public void initLocation(Intent intent) { 123 assert (intent != null); 124 125 // stack is initialized if it's restored from bundle, which means we're restoring a 126 // previously stored state. 127 if (mState.stack.isInitialized()) { 128 if (DEBUG) { 129 Log.d(TAG, "Stack already resolved for uri: " + intent.getData()); 130 } 131 restoreRootAndDirectory(); 132 return; 133 } 134 135 if (launchHomeForCopyDestination(intent)) { 136 if (DEBUG) { 137 Log.d(TAG, "Launching directly into Home directory for copy destination."); 138 } 139 return; 140 } 141 142 if (mFeatures.isLaunchToDocumentEnabled() && launchToInitialUri(intent)) { 143 if (DEBUG) { 144 Log.d(TAG, "Launched to initial uri."); 145 } 146 return; 147 } 148 149 if (DEBUG) { 150 Log.d(TAG, "Load last accessed stack."); 151 } 152 initLoadLastAccessedStack(); 153 } 154 155 @Override launchToDefaultLocation()156 protected void launchToDefaultLocation() { 157 loadLastAccessedStack(); 158 } 159 launchHomeForCopyDestination(Intent intent)160 private boolean launchHomeForCopyDestination(Intent intent) { 161 // As a matter of policy we don't load the last used stack for the copy 162 // destination picker (user is already in Files app). 163 // Consensus was that the experice was too confusing. 164 // In all other cases, where the user is visiting us from another app 165 // we restore the stack as last used from that app. 166 if (Shared.ACTION_PICK_COPY_DESTINATION.equals(intent.getAction())) { 167 loadHomeDir(); 168 return true; 169 } 170 171 return false; 172 } 173 launchToInitialUri(Intent intent)174 private boolean launchToInitialUri(Intent intent) { 175 final Uri initialUri = intent.getParcelableExtra(DocumentsContract.EXTRA_INITIAL_URI); 176 if (initialUri == null) { 177 return false; 178 } 179 180 final boolean isRoot = isRootUri(mActivity, initialUri); 181 final boolean isDocument = !isRoot && isDocumentUri(mActivity, initialUri); 182 183 if (!isRoot && !isDocument) { 184 // Neither a root nor a document. 185 return false; 186 } 187 188 if (isRoot) { 189 loadRoot(initialUri, UserId.DEFAULT_USER); 190 return true; 191 } 192 // From here onwards: isDoc == true. 193 194 if (shouldPreemptivelyRestrictRequestedInitialUri(initialUri)) { 195 Log.w(TAG, "Requested initial URI - " + initialUri + " - is restricted: " 196 + "loading device root instead."); 197 return false; 198 } 199 200 return launchToDocument(initialUri); 201 } 202 203 /** 204 * Starting with Android 11 (R, API Level 30) applications are no longer allowed to use the 205 * {@link Intent#ACTION_OPEN_DOCUMENT} and {@link Intent#ACTION_OPEN_DOCUMENT_TREE} to request 206 * that the user select individual files from "Android/data/", "Android/obb/", 207 * "Android/sandbox/" directories and all their subdirectories on "external storage". 208 * <p> 209 * See <a href="https://developer.android.com/about/versions/11/privacy/storage#file-access"> 210 * Storage updates in Android 11</a>. 211 * <p> 212 * Ideally, this should be handled on the {@code ExternalStorageProvider} side, but as of 213 * Android 14 (U) FRC, {@code ExternalStorageProvider} "hides" only "Android/data/", 214 * "Android/obb/" and "Android/sandbox/" directories, but NOT their subdirectories. 215 */ shouldPreemptivelyRestrictRequestedInitialUri(@onNull Uri uri)216 private boolean shouldPreemptivelyRestrictRequestedInitialUri(@NonNull Uri uri) { 217 // Not restricting SAF access for the calling app. 218 if (!Shared.shouldRestrictStorageAccessFramework(mActivity)) { 219 return false; 220 } 221 222 // We only need to restrict some locations on the "external" storage. 223 if (!Providers.AUTHORITY_STORAGE.equals(uri.getAuthority())) { 224 return false; 225 } 226 227 // TODO(b/283962634): in the future this will have to be platform-version specific. 228 // For example, if the fix on the ExternalStorageProvider side makes it to the Android 15, 229 // we would change this to check if the platform version >= 15. 230 // In the upcoming Android 14 release, however, ExternalStorageProvider does NOT yet 231 // implement this logic. 232 final boolean externalProviderImplementsSafRestrictions = false; 233 if (externalProviderImplementsSafRestrictions) { 234 return false; 235 } 236 237 // External Storage Provider's docId format is "root:path/to/file" 238 // The getPathFromStorageDocId() turns that into "/path/to/file" 239 // Note the missing leading "/" in the path part of the docId, while the path returned by 240 // the getPathFromStorageDocId() start with "/". 241 final String docId = DocumentsContract.getDocumentId(uri); 242 final String filePath; 243 try { 244 filePath = FileUtils.getPathFromStorageDocId(docId); 245 } catch (IOException e) { 246 Log.w(TAG, "Could not get canonical file path from docId '" + docId + "'"); 247 return true; 248 } 249 250 // Check if the app is asking for /Android/data, /Android/obb, /Android/sandbox or any of 251 // their subdirectories (on the external storage). 252 return PATTERN_RESTRICTED_INITIAL_PATH.matcher(filePath).matches(); 253 } 254 initLoadLastAccessedStack()255 private void initLoadLastAccessedStack() { 256 if (DEBUG) { 257 Log.d(TAG, "Attempting to load last used stack for calling package."); 258 } 259 // Block UI until stack is fully loaded, else there is an intermediate incomplete UI state. 260 onLastAccessedStackLoaded(mLastAccessed.getLastAccessed(mActivity, mProviders, mState)); 261 } 262 loadLastAccessedStack()263 private void loadLastAccessedStack() { 264 if (DEBUG) { 265 Log.d(TAG, "Attempting to load last used stack for calling package."); 266 } 267 new LoadLastAccessedStackTask<>( 268 mActivity, mLastAccessed, mState, mProviders, this::onLastAccessedStackLoaded) 269 .execute(); 270 } 271 onLastAccessedStackLoaded(@ullable DocumentStack stack)272 private void onLastAccessedStackLoaded(@Nullable DocumentStack stack) { 273 if (stack == null) { 274 loadDefaultLocation(); 275 } else { 276 mState.stack.reset(stack); 277 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 278 } 279 } 280 getUpdatePickResultTask()281 public UpdatePickResultTask getUpdatePickResultTask() { 282 return mUpdatePickResultTask; 283 } 284 updatePickResult(Intent intent, boolean isSearching, int root)285 private void updatePickResult(Intent intent, boolean isSearching, int root) { 286 ClipData cdata = intent.getClipData(); 287 int fileCount = 0; 288 Uri uri = null; 289 290 // There are 2 cases that would be single-select: 291 // 1. getData() isn't null and getClipData() is null. 292 // 2. getClipData() isn't null and the item count of it is 1. 293 if (intent.getData() != null && cdata == null) { 294 fileCount = 1; 295 uri = intent.getData(); 296 } else if (cdata != null) { 297 fileCount = cdata.getItemCount(); 298 if (fileCount == 1) { 299 uri = cdata.getItemAt(0).getUri(); 300 } 301 } 302 303 mInjector.pickResult.setFileCount(fileCount); 304 mInjector.pickResult.setIsSearching(isSearching); 305 mInjector.pickResult.setRoot(root); 306 mInjector.pickResult.setFileUri(uri); 307 getUpdatePickResultTask().safeExecute(); 308 } 309 loadDefaultLocation()310 private void loadDefaultLocation() { 311 switch (mState.action) { 312 case ACTION_CREATE: 313 loadHomeDir(); 314 break; 315 case ACTION_OPEN_TREE: 316 loadDeviceRoot(); 317 break; 318 case ACTION_GET_CONTENT: 319 case ACTION_OPEN: 320 loadRecent(); 321 break; 322 default: 323 throw new UnsupportedOperationException("Unexpected action type: " + mState.action); 324 } 325 } 326 327 @Override showAppDetails(ResolveInfo info, UserId userId)328 public void showAppDetails(ResolveInfo info, UserId userId) { 329 mInjector.pickResult.increaseActionCount(); 330 final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); 331 intent.setData(Uri.fromParts("package", info.activityInfo.packageName, null)); 332 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); 333 userId.startActivityAsUser(mActivity, intent); 334 } 335 336 @Override openInNewWindow(DocumentStack path)337 public void openInNewWindow(DocumentStack path) { 338 // Open new window support only depends on vanilla Activity, so it is 339 // implemented in our parent class. But we don't support that in 340 // picking. So as a matter of defensiveness, we override that here. 341 throw new UnsupportedOperationException("Can't open in new window"); 342 } 343 344 @Override openRoot(RootInfo root)345 public void openRoot(RootInfo root) { 346 Metrics.logRootVisited(MetricConsts.PICKER_SCOPE, root); 347 mInjector.pickResult.increaseActionCount(); 348 mActivity.onRootPicked(root); 349 } 350 351 @Override openRoot(ResolveInfo info, UserId userId)352 public void openRoot(ResolveInfo info, UserId userId) { 353 Metrics.logAppVisited(info); 354 mInjector.pickResult.increaseActionCount(); 355 356 // The App root item should not show if we cannot interact with the target user. 357 // But the user managed to get here, this is the final check of permission. We don't 358 // perform the check on activity result. 359 if (!mState.canInteractWith(userId)) { 360 mInjector.dialogs.showActionNotAllowed(); 361 return; 362 } 363 364 Intent intent = new Intent(mActivity.getIntent()); 365 final int flagsRemoved = Intent.FLAG_GRANT_READ_URI_PERMISSION 366 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 367 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION 368 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; 369 intent.setFlags(intent.getFlags() & ~flagsRemoved); 370 intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT); 371 intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); 372 intent.setComponent(new ComponentName( 373 info.activityInfo.applicationInfo.packageName, info.activityInfo.name)); 374 try { 375 boolean isCurrentUser = UserId.CURRENT_USER.equals(userId); 376 if (isCurrentUser) { 377 mActivity.startActivity(intent); 378 } else { 379 userId.startActivityAsUser(mActivity, intent); 380 } 381 Metrics.logLaunchOtherApp(!UserId.CURRENT_USER.equals(userId)); 382 mActivity.finish(); 383 } catch (SecurityException | ActivityNotFoundException e) { 384 Log.e(TAG, "Caught error: " + e.getLocalizedMessage()); 385 mInjector.dialogs.showNoApplicationFound(); 386 } 387 } 388 389 390 @Override springOpenDirectory(DocumentInfo doc)391 public void springOpenDirectory(DocumentInfo doc) { 392 } 393 394 @Override openItem(ItemDetails<String> details, @ViewType int type, @ViewType int fallback)395 public boolean openItem(ItemDetails<String> details, @ViewType int type, 396 @ViewType int fallback) { 397 mInjector.pickResult.increaseActionCount(); 398 DocumentInfo doc = mModel.getDocument(details.getSelectionKey()); 399 if (doc == null) { 400 Log.w(TAG, "Can't view item. No Document available for modeId: " 401 + details.getSelectionKey()); 402 return false; 403 } 404 405 if (mConfig.isDocumentEnabled(doc.mimeType, doc.flags, mState)) { 406 mActivity.onDocumentPicked(doc); 407 mSelectionMgr.clearSelection(); 408 return !doc.isDirectory(); 409 } 410 return false; 411 } 412 413 @Override previewItem(ItemDetails<String> details)414 public boolean previewItem(ItemDetails<String> details) { 415 mInjector.pickResult.increaseActionCount(); 416 final DocumentInfo doc = mModel.getDocument(details.getSelectionKey()); 417 if (doc == null) { 418 Log.w(TAG, "Can't view item. No Document available for modeId: " 419 + details.getSelectionKey()); 420 return false; 421 } 422 423 onDocumentOpened(doc, VIEW_TYPE_PREVIEW, VIEW_TYPE_REGULAR, true); 424 return !doc.isContainer(); 425 } 426 pickDocument(FragmentManager fm, DocumentInfo pickTarget)427 void pickDocument(FragmentManager fm, DocumentInfo pickTarget) { 428 assert (pickTarget != null); 429 mInjector.pickResult.increaseActionCount(); 430 Uri result; 431 switch (mState.action) { 432 case ACTION_OPEN_TREE: 433 mInjector.dialogs.confirmAction(fm, pickTarget, ConfirmFragment.TYPE_OEPN_TREE); 434 break; 435 case ACTION_PICK_COPY_DESTINATION: 436 result = pickTarget.derivedUri; 437 finishPicking(result); 438 break; 439 default: 440 // Should not be reached 441 throw new IllegalStateException("Invalid mState.action"); 442 } 443 } 444 saveDocument( String mimeType, String displayName, BooleanConsumer inProgressStateListener)445 void saveDocument( 446 String mimeType, String displayName, BooleanConsumer inProgressStateListener) { 447 assert (mState.action == ACTION_CREATE); 448 mInjector.pickResult.increaseActionCount(); 449 new CreatePickedDocumentTask( 450 mActivity, 451 mDocs, 452 mLastAccessed, 453 mState.stack, 454 mimeType, 455 displayName, 456 inProgressStateListener, 457 this::onPickFinished) 458 .executeOnExecutor(getExecutorForCurrentDirectory()); 459 } 460 461 // User requested to overwrite a target. If confirmed by user #finishPicking() will be 462 // called. saveDocument(FragmentManager fm, DocumentInfo replaceTarget)463 void saveDocument(FragmentManager fm, DocumentInfo replaceTarget) { 464 assert (mState.action == ACTION_CREATE); 465 mInjector.pickResult.increaseActionCount(); 466 assert (replaceTarget != null); 467 468 // Adding a confirmation dialog breaks an inherited CTS test (testCreateExisting), so we 469 // need to add a feature flag to bypass this feature in ARC++ environment. 470 if (mFeatures.isOverwriteConfirmationEnabled()) { 471 mInjector.dialogs.confirmAction(fm, replaceTarget, ConfirmFragment.TYPE_OVERWRITE); 472 } else { 473 finishPicking(replaceTarget.getDocumentUri()); 474 } 475 } 476 finishPicking(Uri... docs)477 void finishPicking(Uri... docs) { 478 new SetLastAccessedStackTask( 479 mActivity, 480 mLastAccessed, 481 mState.stack, 482 () -> { 483 onPickFinished(docs); 484 } 485 ).executeOnExecutor(getExecutorForCurrentDirectory()); 486 } 487 onPickFinished(Uri... uris)488 private void onPickFinished(Uri... uris) { 489 if (DEBUG) { 490 Log.d(TAG, "onFinished() " + Arrays.toString(uris)); 491 } 492 493 final Intent intent = new Intent(); 494 if (uris.length == 1) { 495 intent.setData(uris[0]); 496 } else if (uris.length > 1) { 497 final ClipData clipData = new ClipData( 498 null, mState.acceptMimes, new ClipData.Item(uris[0])); 499 for (int i = 1; i < uris.length; i++) { 500 clipData.addItem(new ClipData.Item(uris[i])); 501 } 502 intent.setClipData(clipData); 503 } 504 505 updatePickResult( 506 intent, mSearchMgr.isSearching(), Metrics.sanitizeRoot(mState.stack.getRoot())); 507 508 // TODO: Separate this piece of logic per action. 509 // We don't instantiate different objects for different actions at the first place, so it's 510 // not a easy task to separate this logic cleanly. 511 // Maybe we can add an ActionPolicy class for IoC and provide various behaviors through its 512 // inheritance structure. 513 if (mState.action == ACTION_GET_CONTENT) { 514 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 515 } else if (mState.action == ACTION_OPEN_TREE) { 516 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION 517 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 518 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION 519 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); 520 } else if (mState.action == ACTION_PICK_COPY_DESTINATION) { 521 // Picking a copy destination is only used internally by us, so we 522 // don't need to extend permissions to the caller. 523 intent.putExtra(Shared.EXTRA_STACK, (Parcelable) mState.stack); 524 intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mState.copyOperationSubType); 525 } else { 526 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION 527 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 528 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); 529 } 530 531 mActivity.setResult(FragmentActivity.RESULT_OK, intent, 0); 532 mActivity.finish(); 533 } 534 getExecutorForCurrentDirectory()535 private Executor getExecutorForCurrentDirectory() { 536 final DocumentInfo cwd = mState.stack.peek(); 537 if (cwd != null && cwd.authority != null) { 538 return mExecutors.lookup(cwd.authority); 539 } else { 540 return AsyncTask.THREAD_POOL_EXECUTOR; 541 } 542 } 543 544 public interface Addons extends CommonAddons { 545 @Override onDocumentPicked(DocumentInfo doc)546 void onDocumentPicked(DocumentInfo doc); 547 548 /** 549 * Overload final method {@link FragmentActivity#setResult(int, Intent)} so that we can 550 * intercept this method call in test environment. 551 */ 552 @VisibleForTesting setResult(int resultCode, Intent result, int notUsed)553 void setResult(int resultCode, Intent result, int notUsed); 554 } 555 }