1 /* 2 * Copyright (C) 2013 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.sidebar; 18 19 import static com.android.documentsui.base.Shared.compareToIgnoreCaseNullable; 20 import static com.android.documentsui.base.SharedMinimal.DEBUG; 21 import static com.android.documentsui.base.SharedMinimal.VERBOSE; 22 23 import android.content.Context; 24 import android.content.Intent; 25 import android.content.pm.PackageManager; 26 import android.content.pm.ResolveInfo; 27 import android.graphics.Color; 28 import android.graphics.drawable.ColorDrawable; 29 import android.os.Bundle; 30 import android.provider.DocumentsContract; 31 import android.text.TextUtils; 32 import android.util.Log; 33 import android.view.ContextMenu; 34 import android.view.DragEvent; 35 import android.view.LayoutInflater; 36 import android.view.MenuItem; 37 import android.view.MotionEvent; 38 import android.view.View; 39 import android.view.View.OnDragListener; 40 import android.view.View.OnGenericMotionListener; 41 import android.view.ViewGroup; 42 import android.widget.AdapterView; 43 import android.widget.AdapterView.AdapterContextMenuInfo; 44 import android.widget.AdapterView.OnItemClickListener; 45 import android.widget.AdapterView.OnItemLongClickListener; 46 import android.widget.ListView; 47 48 import androidx.annotation.Nullable; 49 import androidx.annotation.VisibleForTesting; 50 import androidx.fragment.app.Fragment; 51 import androidx.fragment.app.FragmentManager; 52 import androidx.fragment.app.FragmentTransaction; 53 import androidx.loader.app.LoaderManager; 54 import androidx.loader.app.LoaderManager.LoaderCallbacks; 55 import androidx.loader.content.Loader; 56 57 import com.android.documentsui.ActionHandler; 58 import com.android.documentsui.BaseActivity; 59 import com.android.documentsui.DocumentsApplication; 60 import com.android.documentsui.DragHoverListener; 61 import com.android.documentsui.Injector; 62 import com.android.documentsui.Injector.Injected; 63 import com.android.documentsui.ItemDragListener; 64 import com.android.documentsui.R; 65 import com.android.documentsui.base.BooleanConsumer; 66 import com.android.documentsui.base.DocumentInfo; 67 import com.android.documentsui.base.DocumentStack; 68 import com.android.documentsui.base.Events; 69 import com.android.documentsui.base.RootInfo; 70 import com.android.documentsui.base.Shared; 71 import com.android.documentsui.base.State; 72 import com.android.documentsui.roots.ProvidersAccess; 73 import com.android.documentsui.roots.ProvidersCache; 74 import com.android.documentsui.roots.RootsLoader; 75 76 import java.util.ArrayList; 77 import java.util.Collection; 78 import java.util.Collections; 79 import java.util.Comparator; 80 import java.util.HashMap; 81 import java.util.List; 82 import java.util.Map; 83 import java.util.Objects; 84 85 /** 86 * Display list of known storage backend roots. 87 */ 88 public class RootsFragment extends Fragment { 89 90 private static final String TAG = "RootsFragment"; 91 private static final String EXTRA_INCLUDE_APPS = "includeApps"; 92 private static final String PROFILE_TARGET_ACTIVITY = 93 "com.android.internal.app.IntentForwarderActivity"; 94 private static final int CONTEXT_MENU_ITEM_TIMEOUT = 500; 95 96 private final OnItemClickListener mItemListener = new OnItemClickListener() { 97 @Override 98 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 99 final Item item = mAdapter.getItem(position); 100 item.open(); 101 102 getBaseActivity().setRootsDrawerOpen(false); 103 } 104 }; 105 106 private final OnItemLongClickListener mItemLongClickListener = new OnItemLongClickListener() { 107 @Override 108 public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { 109 final Item item = mAdapter.getItem(position); 110 return item.showAppDetails(); 111 } 112 }; 113 114 private ListView mList; 115 private RootsAdapter mAdapter; 116 private LoaderCallbacks<Collection<RootInfo>> mCallbacks; 117 private @Nullable OnDragListener mDragListener; 118 119 @Injected 120 private Injector<?> mInjector; 121 122 @Injected 123 private ActionHandler mActionHandler; 124 125 private List<Item> mApplicationItemList; 126 show(FragmentManager fm, Intent includeApps)127 public static RootsFragment show(FragmentManager fm, Intent includeApps) { 128 final Bundle args = new Bundle(); 129 args.putParcelable(EXTRA_INCLUDE_APPS, includeApps); 130 131 final RootsFragment fragment = new RootsFragment(); 132 fragment.setArguments(args); 133 134 final FragmentTransaction ft = fm.beginTransaction(); 135 ft.replace(R.id.container_roots, fragment); 136 ft.commitAllowingStateLoss(); 137 138 return fragment; 139 } 140 get(FragmentManager fm)141 public static RootsFragment get(FragmentManager fm) { 142 return (RootsFragment) fm.findFragmentById(R.id.container_roots); 143 } 144 145 @Override onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)146 public View onCreateView( 147 LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 148 149 mInjector = getBaseActivity().getInjector(); 150 151 final View view = inflater.inflate(R.layout.fragment_roots, container, false); 152 mList = (ListView) view.findViewById(R.id.roots_list); 153 mList.setOnItemClickListener(mItemListener); 154 // ListView does not have right-click specific listeners, so we will have a 155 // GenericMotionListener to listen for it. 156 // Currently, right click is viewed the same as long press, so we will have to quickly 157 // register for context menu when we receive a right click event, and quickly unregister 158 // it afterwards to prevent context menus popping up upon long presses. 159 // All other motion events will then get passed to OnItemClickListener. 160 mList.setOnGenericMotionListener( 161 new OnGenericMotionListener() { 162 @Override 163 public boolean onGenericMotion(View v, MotionEvent event) { 164 if (Events.isMouseEvent(event) 165 && event.getButtonState() == MotionEvent.BUTTON_SECONDARY) { 166 int x = (int) event.getX(); 167 int y = (int) event.getY(); 168 return onRightClick(v, x, y, () -> { 169 mInjector.menuManager.showContextMenu( 170 RootsFragment.this, v, x, y); 171 }); 172 } 173 return false; 174 } 175 }); 176 mList.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 177 mList.setSelector(new ColorDrawable(Color.TRANSPARENT)); 178 return view; 179 } 180 onRightClick(View v, int x, int y, Runnable callback)181 private boolean onRightClick(View v, int x, int y, Runnable callback) { 182 final int pos = mList.pointToPosition(x, y); 183 final Item item = mAdapter.getItem(pos); 184 185 // If a read-only root, no need to see if top level is writable (it's not) 186 if (!(item instanceof RootItem) || !((RootItem) item).root.supportsCreate()) { 187 return false; 188 } 189 190 final RootItem rootItem = (RootItem) item; 191 getRootDocument(rootItem, (DocumentInfo doc) -> { 192 rootItem.docInfo = doc; 193 callback.run(); 194 }); 195 return true; 196 } 197 198 @Override onActivityCreated(Bundle savedInstanceState)199 public void onActivityCreated(Bundle savedInstanceState) { 200 super.onActivityCreated(savedInstanceState); 201 202 final BaseActivity activity = getBaseActivity(); 203 final ProvidersCache providers = DocumentsApplication.getProvidersCache(activity); 204 final State state = activity.getDisplayState(); 205 206 mActionHandler = mInjector.actions; 207 208 if (mInjector.config.dragAndDropEnabled()) { 209 final DragHost host = new DragHost( 210 activity, 211 DocumentsApplication.getDragAndDropManager(activity), 212 this::getItem, 213 mActionHandler); 214 final ItemDragListener<DragHost> listener = new ItemDragListener<DragHost>(host) { 215 @Override 216 public boolean handleDropEventChecked(View v, DragEvent event) { 217 final Item item = getItem(v); 218 219 assert (item.isRoot()); 220 221 return item.dropOn(event); 222 } 223 }; 224 mDragListener = DragHoverListener.create(listener, mList); 225 mList.setOnDragListener(mDragListener); 226 } 227 228 mCallbacks = new LoaderCallbacks<Collection<RootInfo>>() { 229 @Override 230 public Loader<Collection<RootInfo>> onCreateLoader(int id, Bundle args) { 231 return new RootsLoader(activity, providers, state); 232 } 233 234 @Override 235 public void onLoadFinished( 236 Loader<Collection<RootInfo>> loader, Collection<RootInfo> roots) { 237 if (!isAdded()) { 238 return; 239 } 240 241 Intent handlerAppIntent = getArguments().getParcelable(EXTRA_INCLUDE_APPS); 242 243 final Intent intent = activity.getIntent(); 244 final boolean excludeSelf = 245 intent.getBooleanExtra(DocumentsContract.EXTRA_EXCLUDE_SELF, false); 246 final String excludePackage = excludeSelf ? activity.getCallingPackage() : null; 247 List<Item> sortedItems = sortLoadResult(roots, excludePackage, handlerAppIntent, 248 DocumentsApplication.getProvidersCache(getContext())); 249 mAdapter = new RootsAdapter(activity, sortedItems, mDragListener); 250 mList.setAdapter(mAdapter); 251 252 mInjector.shortcutsUpdater.accept(roots); 253 mInjector.appsRowManager.updateList(mApplicationItemList); 254 mInjector.appsRowManager.updateView(activity); 255 onCurrentRootChanged(); 256 } 257 258 @Override 259 public void onLoaderReset(Loader<Collection<RootInfo>> loader) { 260 mAdapter = null; 261 mList.setAdapter(null); 262 } 263 }; 264 } 265 266 /** 267 * If the package name of other providers or apps capable of handling the original intent 268 * include the preferred root source, it will have higher order than others. 269 * @param excludePackage Exclude activities from this given package 270 * @param handlerAppIntent When not null, apps capable of handling the original intent will 271 * be included in list of roots (in special section at bottom). 272 */ 273 @VisibleForTesting sortLoadResult( Collection<RootInfo> roots, @Nullable String excludePackage, @Nullable Intent handlerAppIntent, ProvidersAccess providersAccess)274 List<Item> sortLoadResult( 275 Collection<RootInfo> roots, 276 @Nullable String excludePackage, 277 @Nullable Intent handlerAppIntent, 278 ProvidersAccess providersAccess) { 279 final List<Item> result = new ArrayList<>(); 280 281 final List<RootItem> libraries = new ArrayList<>(); 282 final List<RootItem> storageProviders = new ArrayList<>(); 283 final List<RootItem> otherProviders = new ArrayList<>(); 284 285 for (final RootInfo root : roots) { 286 final RootItem item; 287 288 if (root.isExternalStorageHome() && !Shared.shouldShowDocumentsRoot(getContext())) { 289 continue; 290 } else if (root.isLibrary() || root.isDownloads()) { 291 item = new RootItem(root, mActionHandler); 292 libraries.add(item); 293 } else if (root.isStorage()) { 294 item = new RootItem(root, mActionHandler); 295 storageProviders.add(item); 296 } else { 297 item = new RootItem(root, mActionHandler, 298 providersAccess.getPackageName(root.authority)); 299 otherProviders.add(item); 300 } 301 } 302 303 final RootComparator comp = new RootComparator(); 304 Collections.sort(libraries, comp); 305 Collections.sort(storageProviders, comp); 306 307 if (VERBOSE) Log.v(TAG, "Adding library roots: " + libraries); 308 result.addAll(libraries); 309 310 // Only add the spacer if it is actually separating something. 311 if (!result.isEmpty() && !storageProviders.isEmpty()) { 312 result.add(new SpacerItem()); 313 } 314 if (VERBOSE) Log.v(TAG, "Adding storage roots: " + storageProviders); 315 result.addAll(storageProviders); 316 317 // Include apps that can handle this intent too. 318 if (handlerAppIntent != null) { 319 includeHandlerApps(handlerAppIntent, excludePackage, result, otherProviders); 320 } else { 321 // Only add providers 322 Collections.sort(otherProviders, comp); 323 if (!result.isEmpty() && !otherProviders.isEmpty()) { 324 result.add(new SpacerItem()); 325 } 326 if (VERBOSE) Log.v(TAG, "Adding plain roots: " + otherProviders); 327 result.addAll(otherProviders); 328 329 mApplicationItemList = new ArrayList<>(); 330 for (Item item : otherProviders) { 331 mApplicationItemList.add(item); 332 } 333 } 334 335 return result; 336 } 337 338 /** 339 * Adds apps capable of handling the original intent will be included in list of roots. If 340 * the providers and apps are the same package name, combine them as RootAndAppItems. 341 */ includeHandlerApps( Intent handlerAppIntent, @Nullable String excludePackage, List<Item> result, List<RootItem> otherProviders)342 private void includeHandlerApps( 343 Intent handlerAppIntent, @Nullable String excludePackage, List<Item> result, 344 List<RootItem> otherProviders) { 345 if (VERBOSE) Log.v(TAG, "Adding handler apps for intent: " + handlerAppIntent); 346 Context context = getContext(); 347 final PackageManager pm = context.getPackageManager(); 348 final List<ResolveInfo> infos = pm.queryIntentActivities( 349 handlerAppIntent, PackageManager.MATCH_DEFAULT_ONLY); 350 351 final List<Item> rootList = new ArrayList<>(); 352 final Map<String, ResolveInfo> appsMapping = new HashMap<>(); 353 final Map<String, Item> appItems = new HashMap<>(); 354 ProfileItem profileItem = null; 355 356 // Omit ourselves and maybe calling package from the list 357 for (ResolveInfo info : infos) { 358 final String packageName = info.activityInfo.packageName; 359 if (!context.getPackageName().equals(packageName) && 360 !TextUtils.equals(excludePackage, packageName)) { 361 appsMapping.put(packageName, info); 362 363 // for change personal profile root. 364 if (PROFILE_TARGET_ACTIVITY.equals(info.activityInfo.targetActivity)) { 365 profileItem = new ProfileItem(info, info.loadLabel(pm).toString(), 366 mActionHandler); 367 } else { 368 final Item item = new AppItem(info, info.loadLabel(pm).toString(), 369 mActionHandler); 370 appItems.put(packageName, item); 371 if (VERBOSE) Log.v(TAG, "Adding handler app: " + item); 372 } 373 } 374 } 375 376 // If there are some providers and apps has the same package name, combine them as one item. 377 for (RootItem rootItem : otherProviders) { 378 final String packageName = rootItem.getPackageName(); 379 final ResolveInfo resolveInfo = appsMapping.get(packageName); 380 381 final Item item; 382 if (resolveInfo != null) { 383 item = new RootAndAppItem(rootItem.root, resolveInfo, mActionHandler); 384 appItems.remove(packageName); 385 } else { 386 item = rootItem; 387 } 388 389 if (VERBOSE) Log.v(TAG, "Adding provider : " + item); 390 rootList.add(item); 391 } 392 393 rootList.addAll(appItems.values()); 394 395 if (!result.isEmpty() && !rootList.isEmpty()) { 396 result.add(new SpacerItem()); 397 } 398 399 final String preferredRootPackage = getResources().getString( 400 R.string.preferred_root_package, ""); 401 402 final ItemComparator comp = new ItemComparator(preferredRootPackage); 403 Collections.sort(rootList, comp); 404 result.addAll(rootList); 405 406 mApplicationItemList = rootList; 407 408 if (profileItem != null) { 409 result.add(new SpacerItem()); 410 result.add(profileItem); 411 } 412 } 413 414 @Override onResume()415 public void onResume() { 416 super.onResume(); 417 onDisplayStateChanged(); 418 } 419 onDisplayStateChanged()420 public void onDisplayStateChanged() { 421 final Context context = getActivity(); 422 final State state = ((BaseActivity) context).getDisplayState(); 423 424 if (state.action == State.ACTION_GET_CONTENT) { 425 mList.setOnItemLongClickListener(mItemLongClickListener); 426 } else { 427 mList.setOnItemLongClickListener(null); 428 mList.setLongClickable(false); 429 } 430 431 LoaderManager.getInstance(this).restartLoader(2, null, mCallbacks); 432 } 433 onCurrentRootChanged()434 public void onCurrentRootChanged() { 435 if (mAdapter == null) { 436 return; 437 } 438 439 final RootInfo root = ((BaseActivity) getActivity()).getCurrentRoot(); 440 for (int i = 0; i < mAdapter.getCount(); i++) { 441 final Object item = mAdapter.getItem(i); 442 if (item instanceof RootItem) { 443 final RootInfo testRoot = ((RootItem) item).root; 444 if (Objects.equals(testRoot, root)) { 445 // b/37358441 should reload all root title after configuration changed 446 root.title = testRoot.title; 447 mList.setItemChecked(i, true); 448 return; 449 } 450 } 451 } 452 } 453 454 /** 455 * Attempts to shift focus back to the navigation drawer. 456 */ requestFocus()457 public boolean requestFocus() { 458 return mList.requestFocus(); 459 } 460 getBaseActivity()461 private BaseActivity getBaseActivity() { 462 return (BaseActivity) getActivity(); 463 } 464 465 @Override onCreateContextMenu( ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo)466 public void onCreateContextMenu( 467 ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { 468 super.onCreateContextMenu(menu, v, menuInfo); 469 AdapterContextMenuInfo adapterMenuInfo = (AdapterContextMenuInfo) menuInfo; 470 final Item item = mAdapter.getItem(adapterMenuInfo.position); 471 472 BaseActivity activity = getBaseActivity(); 473 item.createContextMenu(menu, activity.getMenuInflater(), mInjector.menuManager); 474 } 475 476 @Override onContextItemSelected(MenuItem item)477 public boolean onContextItemSelected(MenuItem item) { 478 AdapterContextMenuInfo adapterMenuInfo = (AdapterContextMenuInfo) item.getMenuInfo(); 479 // There is a possibility that this is called from DirectoryFragment since 480 // all fragments' onContextItemSelected gets called when any menu item is selected 481 // This is to guard against it since DirectoryFragment's RecylerView does not have a 482 // menuInfo 483 if (adapterMenuInfo == null) { 484 return false; 485 } 486 final RootItem rootItem = (RootItem) mAdapter.getItem(adapterMenuInfo.position); 487 switch (item.getItemId()) { 488 case R.id.root_menu_eject_root: 489 final View ejectIcon = adapterMenuInfo.targetView.findViewById(R.id.action_icon); 490 ejectClicked(ejectIcon, rootItem.root, mActionHandler); 491 return true; 492 case R.id.root_menu_open_in_new_window: 493 mActionHandler.openInNewWindow(new DocumentStack(rootItem.root)); 494 return true; 495 case R.id.root_menu_paste_into_folder: 496 mActionHandler.pasteIntoFolder(rootItem.root); 497 return true; 498 case R.id.root_menu_settings: 499 mActionHandler.openSettings(rootItem.root); 500 return true; 501 default: 502 if (DEBUG) { 503 Log.d(TAG, "Unhandled menu item selected: " + item); 504 } 505 return false; 506 } 507 } 508 getRootDocument(RootItem rootItem, RootUpdater updater)509 private void getRootDocument(RootItem rootItem, RootUpdater updater) { 510 // We need to start a GetRootDocumentTask so we can know whether items can be directly 511 // pasted into root 512 mActionHandler.getRootDocument( 513 rootItem.root, 514 CONTEXT_MENU_ITEM_TIMEOUT, 515 (DocumentInfo doc) -> { 516 updater.updateDocInfoForRoot(doc); 517 }); 518 } 519 getItem(View v)520 private Item getItem(View v) { 521 final int pos = (Integer) v.getTag(R.id.item_position_tag); 522 return mAdapter.getItem(pos); 523 } 524 ejectClicked(View ejectIcon, RootInfo root, ActionHandler actionHandler)525 static void ejectClicked(View ejectIcon, RootInfo root, ActionHandler actionHandler) { 526 assert(ejectIcon != null); 527 assert(!root.ejecting); 528 ejectIcon.setEnabled(false); 529 root.ejecting = true; 530 actionHandler.ejectRoot( 531 root, 532 new BooleanConsumer() { 533 @Override 534 public void accept(boolean ejected) { 535 // Event if ejected is false, we should reset, since the op failed. 536 // Either way, we are no longer attempting to eject the device. 537 root.ejecting = false; 538 539 // If the view is still visible, we update its state. 540 if (ejectIcon.getVisibility() == View.VISIBLE) { 541 ejectIcon.setEnabled(!ejected); 542 } 543 } 544 }); 545 } 546 547 private static class RootComparator implements Comparator<RootItem> { 548 @Override compare(RootItem lhs, RootItem rhs)549 public int compare(RootItem lhs, RootItem rhs) { 550 return lhs.root.compareTo(rhs.root); 551 } 552 } 553 554 /** 555 * The comparator of {@link AppItem}, {@link RootItem} and {@link RootAndAppItem}. 556 * Sort by if the item's package name starts with the preferred package name, 557 * then title, then summary. Because the {@link AppItem} doesn't have summary, 558 * it will have lower order than other same title items. 559 */ 560 @VisibleForTesting 561 static class ItemComparator implements Comparator<Item> { 562 private final String mPreferredPackageName; 563 ItemComparator(String preferredPackageName)564 ItemComparator(String preferredPackageName) { 565 mPreferredPackageName = preferredPackageName; 566 } 567 568 @Override compare(Item lhs, Item rhs)569 public int compare(Item lhs, Item rhs) { 570 // Sort by whether the item starts with preferred package name 571 if (!mPreferredPackageName.isEmpty()) { 572 if (lhs.getPackageName().startsWith(mPreferredPackageName)) { 573 if (!rhs.getPackageName().startsWith(mPreferredPackageName)) { 574 // lhs starts with it, but rhs doesn't start with it 575 return -1; 576 } 577 } else { 578 if (rhs.getPackageName().startsWith(mPreferredPackageName)) { 579 // lhs doesn't start with it, but rhs starts with it 580 return 1; 581 } 582 } 583 } 584 585 // Sort by title 586 int score = compareToIgnoreCaseNullable(lhs.title, rhs.title); 587 if (score != 0) { 588 return score; 589 } 590 591 // Sort by summary. If the item is AppItem, it doesn't have summary. 592 // So, the RootItem or RootAndAppItem will have higher order than AppItem. 593 if (lhs instanceof RootItem) { 594 return rhs instanceof RootItem ? compareToIgnoreCaseNullable( 595 ((RootItem) lhs).root.summary, ((RootItem) rhs).root.summary) : 1; 596 } 597 return rhs instanceof RootItem ? -1 : 0; 598 } 599 } 600 601 @FunctionalInterface 602 interface RootUpdater { updateDocInfoForRoot(DocumentInfo doc)603 void updateDocInfoForRoot(DocumentInfo doc); 604 } 605 } 606