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