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.Shared.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.app.Activity; 27 import android.app.FragmentManager; 28 import android.content.ClipData; 29 import android.content.ComponentName; 30 import android.content.Intent; 31 import android.content.pm.ResolveInfo; 32 import android.net.Uri; 33 import android.os.AsyncTask; 34 import android.os.Parcelable; 35 import android.provider.DocumentsContract; 36 import android.provider.Settings; 37 import android.util.Log; 38 39 import com.android.documentsui.AbstractActionHandler; 40 import com.android.documentsui.ActivityConfig; 41 import com.android.documentsui.DocumentsAccess; 42 import com.android.documentsui.Injector; 43 import com.android.documentsui.Metrics; 44 import com.android.documentsui.base.BooleanConsumer; 45 import com.android.documentsui.base.DocumentInfo; 46 import com.android.documentsui.base.DocumentStack; 47 import com.android.documentsui.base.Features; 48 import com.android.documentsui.base.Lookup; 49 import com.android.documentsui.base.RootInfo; 50 import com.android.documentsui.base.Shared; 51 import com.android.documentsui.base.State; 52 import com.android.documentsui.dirlist.AnimationView; 53 import com.android.documentsui.dirlist.DocumentDetails; 54 import com.android.documentsui.Model; 55 import com.android.documentsui.picker.ActionHandler.Addons; 56 import com.android.documentsui.queries.SearchViewManager; 57 import com.android.documentsui.roots.ProvidersAccess; 58 import com.android.documentsui.services.FileOperationService; 59 import com.android.internal.annotations.VisibleForTesting; 60 61 import java.util.Arrays; 62 import java.util.concurrent.Executor; 63 64 import javax.annotation.Nullable; 65 66 /** 67 * Provides {@link PickActivity} action specializations to fragments. 68 */ 69 class ActionHandler<T extends Activity & Addons> extends AbstractActionHandler<T> { 70 71 private static final String TAG = "PickerActionHandler"; 72 73 private final Features mFeatures; 74 private final ActivityConfig mConfig; 75 private final Model mModel; 76 private final LastAccessedStorage mLastAccessed; 77 ActionHandler( T activity, State state, ProvidersAccess providers, DocumentsAccess docs, SearchViewManager searchMgr, Lookup<String, Executor> executors, Injector injector, LastAccessedStorage lastAccessed)78 ActionHandler( 79 T activity, 80 State state, 81 ProvidersAccess providers, 82 DocumentsAccess docs, 83 SearchViewManager searchMgr, 84 Lookup<String, Executor> executors, 85 Injector injector, 86 LastAccessedStorage lastAccessed) { 87 88 super(activity, state, providers, docs, searchMgr, executors, injector); 89 90 mConfig = injector.config; 91 mFeatures = injector.features; 92 mModel = injector.getModel(); 93 mLastAccessed = lastAccessed; 94 } 95 96 @Override initLocation(Intent intent)97 public void initLocation(Intent intent) { 98 assert(intent != null); 99 100 // stack is initialized if it's restored from bundle, which means we're restoring a 101 // previously stored state. 102 if (mState.stack.isInitialized()) { 103 if (DEBUG) Log.d(TAG, "Stack already resolved for uri: " + intent.getData()); 104 return; 105 } 106 107 // We set the activity title in AsyncTask.onPostExecute(). 108 // To prevent talkback from reading aloud the default title, we clear it here. 109 mActivity.setTitle(""); 110 111 if (launchHomeForCopyDestination(intent)) { 112 if (DEBUG) Log.d(TAG, "Launching directly into Home directory for copy destination."); 113 return; 114 } 115 116 if (mFeatures.isLaunchToDocumentEnabled() && launchToDocument(intent)) { 117 if (DEBUG) Log.d(TAG, "Launched to a document."); 118 return; 119 } 120 121 if (DEBUG) Log.d(TAG, "Load last accessed stack."); 122 loadLastAccessedStack(); 123 } 124 125 @Override launchToDefaultLocation()126 protected void launchToDefaultLocation() { 127 loadLastAccessedStack(); 128 } 129 launchHomeForCopyDestination(Intent intent)130 private boolean launchHomeForCopyDestination(Intent intent) { 131 // As a matter of policy we don't load the last used stack for the copy 132 // destination picker (user is already in Files app). 133 // Consensus was that the experice was too confusing. 134 // In all other cases, where the user is visiting us from another app 135 // we restore the stack as last used from that app. 136 if (Shared.ACTION_PICK_COPY_DESTINATION.equals(intent.getAction())) { 137 loadHomeDir(); 138 return true; 139 } 140 141 return false; 142 } 143 launchToDocument(Intent intent)144 private boolean launchToDocument(Intent intent) { 145 Uri uri = intent.getParcelableExtra(DocumentsContract.EXTRA_INITIAL_URI); 146 if (uri != null) { 147 return launchToDocument(uri); 148 } 149 150 return false; 151 } 152 loadLastAccessedStack()153 private void loadLastAccessedStack() { 154 if (DEBUG) Log.d(TAG, "Attempting to load last used stack for calling package."); 155 new LoadLastAccessedStackTask<>( 156 mActivity, mLastAccessed, mState, mProviders, this::onLastAccessedStackLoaded) 157 .execute(); 158 } 159 onLastAccessedStackLoaded(@ullable DocumentStack stack)160 private void onLastAccessedStackLoaded(@Nullable DocumentStack stack) { 161 if (stack == null) { 162 loadDefaultLocation(); 163 } else { 164 mState.stack.reset(stack); 165 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 166 } 167 } 168 loadDefaultLocation()169 private void loadDefaultLocation() { 170 switch (mState.action) { 171 case ACTION_CREATE: 172 loadHomeDir(); 173 break; 174 case ACTION_GET_CONTENT: 175 case ACTION_OPEN: 176 case ACTION_OPEN_TREE: 177 mState.stack.changeRoot(mProviders.getRecentsRoot()); 178 mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); 179 break; 180 default: 181 throw new UnsupportedOperationException("Unexpected action type: " + mState.action); 182 } 183 } 184 185 @Override showAppDetails(ResolveInfo info)186 public void showAppDetails(ResolveInfo info) { 187 final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); 188 intent.setData(Uri.fromParts("package", info.activityInfo.packageName, null)); 189 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT); 190 mActivity.startActivity(intent); 191 } 192 193 @Override onActivityResult(int requestCode, int resultCode, Intent data)194 public void onActivityResult(int requestCode, int resultCode, Intent data) { 195 if (DEBUG) Log.d(TAG, "onActivityResult() code=" + resultCode); 196 197 // Only relay back results when not canceled; otherwise stick around to 198 // let the user pick another app/backend. 199 switch (requestCode) { 200 case CODE_FORWARD: 201 onExternalAppResult(resultCode, data); 202 break; 203 default: 204 super.onActivityResult(requestCode, resultCode, data); 205 } 206 } 207 onExternalAppResult(int resultCode, Intent data)208 private void onExternalAppResult(int resultCode, Intent data) { 209 if (resultCode != Activity.RESULT_CANCELED) { 210 // Remember that we last picked via external app 211 mLastAccessed.setLastAccessedToExternalApp(mActivity); 212 213 // Pass back result to original caller 214 mActivity.setResult(resultCode, data, 0); 215 mActivity.finish(); 216 } 217 } 218 219 @Override openInNewWindow(DocumentStack path)220 public void openInNewWindow(DocumentStack path) { 221 // Open new window support only depends on vanilla Activity, so it is 222 // implemented in our parent class. But we don't support that in 223 // picking. So as a matter of defensiveness, we override that here. 224 throw new UnsupportedOperationException("Can't open in new window"); 225 } 226 227 @Override openRoot(RootInfo root)228 public void openRoot(RootInfo root) { 229 Metrics.logRootVisited(mActivity, Metrics.PICKER_SCOPE, root); 230 mActivity.onRootPicked(root); 231 } 232 233 @Override openRoot(ResolveInfo info)234 public void openRoot(ResolveInfo info) { 235 Metrics.logAppVisited(mActivity, info); 236 final Intent intent = new Intent(mActivity.getIntent()); 237 intent.setFlags(intent.getFlags() & ~Intent.FLAG_ACTIVITY_FORWARD_RESULT); 238 intent.setComponent(new ComponentName( 239 info.activityInfo.applicationInfo.packageName, info.activityInfo.name)); 240 mActivity.startActivityForResult(intent, CODE_FORWARD); 241 } 242 243 @Override springOpenDirectory(DocumentInfo doc)244 public void springOpenDirectory(DocumentInfo doc) { 245 } 246 247 @Override openDocument(DocumentDetails details, @ViewType int type, @ViewType int fallback)248 public boolean openDocument(DocumentDetails details, @ViewType int type, 249 @ViewType int fallback) { 250 DocumentInfo doc = mModel.getDocument(details.getModelId()); 251 if (doc == null) { 252 Log.w(TAG, 253 "Can't view item. No Document available for modeId: " + details.getModelId()); 254 return false; 255 } 256 257 if (mConfig.isDocumentEnabled(doc.mimeType, doc.flags, mState)) { 258 mActivity.onDocumentPicked(doc); 259 mSelectionMgr.clearSelection(); 260 return true; 261 } 262 return false; 263 } 264 pickDocument(DocumentInfo pickTarget)265 void pickDocument(DocumentInfo pickTarget) { 266 assert(pickTarget != null); 267 Uri result; 268 switch (mState.action) { 269 case ACTION_OPEN_TREE: 270 result = DocumentsContract.buildTreeDocumentUri( 271 pickTarget.authority, pickTarget.documentId); 272 break; 273 case ACTION_PICK_COPY_DESTINATION: 274 result = pickTarget.derivedUri; 275 break; 276 default: 277 // Should not be reached 278 throw new IllegalStateException("Invalid mState.action"); 279 } 280 finishPicking(result); 281 } 282 saveDocument( String mimeType, String displayName, BooleanConsumer inProgressStateListener)283 void saveDocument( 284 String mimeType, String displayName, BooleanConsumer inProgressStateListener) { 285 assert(mState.action == ACTION_CREATE); 286 new CreatePickedDocumentTask( 287 mActivity, 288 mDocs, 289 mLastAccessed, 290 mState.stack, 291 mimeType, 292 displayName, 293 inProgressStateListener, 294 this::onPickFinished) 295 .executeOnExecutor(getExecutorForCurrentDirectory()); 296 } 297 298 // User requested to overwrite a target. If confirmed by user #finishPicking() will be 299 // called. saveDocument(FragmentManager fm, DocumentInfo replaceTarget)300 void saveDocument(FragmentManager fm, DocumentInfo replaceTarget) { 301 assert(mState.action == ACTION_CREATE); 302 assert(replaceTarget != null); 303 304 mInjector.dialogs.confirmOverwrite(fm, replaceTarget); 305 } 306 finishPicking(Uri... docs)307 void finishPicking(Uri... docs) { 308 new SetLastAccessedStackTask( 309 mActivity, 310 mLastAccessed, 311 mState.stack, 312 () -> { 313 onPickFinished(docs); 314 } 315 ) .executeOnExecutor(getExecutorForCurrentDirectory()); 316 } 317 onPickFinished(Uri... uris)318 private void onPickFinished(Uri... uris) { 319 if (DEBUG) Log.d(TAG, "onFinished() " + Arrays.toString(uris)); 320 321 final Intent intent = new Intent(); 322 if (uris.length == 1) { 323 intent.setData(uris[0]); 324 } else if (uris.length > 1) { 325 final ClipData clipData = new ClipData( 326 null, mState.acceptMimes, new ClipData.Item(uris[0])); 327 for (int i = 1; i < uris.length; i++) { 328 clipData.addItem(new ClipData.Item(uris[i])); 329 } 330 intent.setClipData(clipData); 331 } 332 333 // TODO: Separate this piece of logic per action. 334 // We don't instantiate different objects for different actions at the first place, so it's 335 // not a easy task to separate this logic cleanly. 336 // Maybe we can add an ActionPolicy class for IoC and provide various behaviors through its 337 // inheritance structure. 338 if (mState.action == ACTION_GET_CONTENT) { 339 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 340 } else if (mState.action == ACTION_OPEN_TREE) { 341 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION 342 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 343 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION 344 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); 345 } else if (mState.action == ACTION_PICK_COPY_DESTINATION) { 346 // Picking a copy destination is only used internally by us, so we 347 // don't need to extend permissions to the caller. 348 intent.putExtra(Shared.EXTRA_STACK, (Parcelable) mState.stack); 349 intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mState.copyOperationSubType); 350 } else { 351 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION 352 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 353 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); 354 } 355 356 mActivity.setResult(Activity.RESULT_OK, intent, 0); 357 mActivity.finish(); 358 } 359 getExecutorForCurrentDirectory()360 private Executor getExecutorForCurrentDirectory() { 361 final DocumentInfo cwd = mState.stack.peek(); 362 if (cwd != null && cwd.authority != null) { 363 return mExecutors.lookup(cwd.authority); 364 } else { 365 return AsyncTask.THREAD_POOL_EXECUTOR; 366 } 367 } 368 369 public interface Addons extends CommonAddons { onDocumentPicked(DocumentInfo doc)370 void onDocumentPicked(DocumentInfo doc); 371 372 /** 373 * Overload final method {@link Activity#setResult(int, Intent)} so that we can intercept 374 * this method call in test environment. 375 */ 376 @VisibleForTesting setResult(int resultCode, Intent result, int notUsed)377 void setResult(int resultCode, Intent result, int notUsed); 378 } 379 } 380