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