diff options
Diffstat (limited to 'src')
61 files changed, 3011 insertions, 532 deletions
diff --git a/src/com/android/documentsui/AbstractActionHandler.java b/src/com/android/documentsui/AbstractActionHandler.java index 2f64ebf64..de193e235 100644 --- a/src/com/android/documentsui/AbstractActionHandler.java +++ b/src/com/android/documentsui/AbstractActionHandler.java @@ -19,6 +19,8 @@ package com.android.documentsui; import static com.android.documentsui.base.DocumentInfo.getCursorInt; import static com.android.documentsui.base.DocumentInfo.getCursorString; import static com.android.documentsui.base.SharedMinimal.DEBUG; +import static com.android.documentsui.util.FlagUtils.isDesktopFileHandlingFlagEnabled; +import static com.android.documentsui.util.FlagUtils.isUseSearchV2RwFlagEnabled; import android.app.PendingIntent; import android.content.ActivityNotFoundException; @@ -37,6 +39,7 @@ import android.util.Log; import android.util.Pair; import android.view.DragEvent; +import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import androidx.fragment.app.FragmentActivity; import androidx.loader.app.LoaderManager.LoaderCallbacks; @@ -62,6 +65,9 @@ import com.android.documentsui.dirlist.AnimationView.AnimationType; import com.android.documentsui.dirlist.FocusHandler; import com.android.documentsui.files.LauncherActivity; import com.android.documentsui.files.QuickViewIntentBuilder; +import com.android.documentsui.loaders.FolderLoader; +import com.android.documentsui.loaders.QueryOptions; +import com.android.documentsui.loaders.SearchLoader; import com.android.documentsui.queries.SearchViewManager; import com.android.documentsui.roots.GetRootDocumentTask; import com.android.documentsui.roots.LoadFirstRootTask; @@ -72,10 +78,14 @@ import com.android.documentsui.sorting.SortListFragment; import com.android.documentsui.ui.DialogController; import com.android.documentsui.ui.Snackbars; +import java.time.Duration; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; import java.util.function.Consumer; @@ -251,7 +261,12 @@ public abstract class AbstractActionHandler<T extends FragmentActivity & CommonA } @Override - public void showInspector(DocumentInfo doc) { + public void openDocumentViewOnly(DocumentInfo doc) { + throw new UnsupportedOperationException("Open doc not supported!"); + } + + @Override + public void showPreview(DocumentInfo doc) { throw new UnsupportedOperationException("Can't open properties."); } @@ -560,6 +575,15 @@ public abstract class AbstractActionHandler<T extends FragmentActivity & CommonA if (doc.isWriteSupported()) { flags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; } + // On desktop users expect files to open in a new window. + if (isDesktopFileHandlingFlagEnabled()) { + // The combination of NEW_DOCUMENT and MULTIPLE_TASK allows multiple instances of the + // same activity to open in separate windows. + flags |= Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; + // If the activity has documentLaunchMode="never", NEW_TASK forces the activity to still + // open in a new window. + flags |= Intent.FLAG_ACTIVITY_NEW_TASK; + } intent.setFlags(flags); return intent; @@ -879,16 +903,28 @@ public abstract class AbstractActionHandler<T extends FragmentActivity & CommonA private final class LoaderBindings implements LoaderCallbacks<DirectoryResult> { + private ExecutorService mExecutorService = null; + private static final long MAX_SEARCH_TIME_MS = 3000; + private static final int MAX_RESULTS = 500; + + @NonNull @Override public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) { - Context context = mActivity; - // If document stack is not initialized, i.e. if the root is null, create "Recents" root // with the selected user. if (!mState.stack.isInitialized()) { mState.stack.changeRoot(mActivity.getCurrentRoot()); } + if (isUseSearchV2RwFlagEnabled()) { + return onCreateLoaderV2(id, args); + } + return onCreateLoaderV1(id, args); + } + + private Loader<DirectoryResult> onCreateLoaderV1(int id, Bundle args) { + Context context = mActivity; + if (mState.stack.isRecents()) { final LockingContentObserver observer = new LockingContentObserver( mContentLock, AbstractActionHandler.this::loadDocumentsForCurrentStack); @@ -965,6 +1001,69 @@ public abstract class AbstractActionHandler<T extends FragmentActivity & CommonA } } + private Loader<DirectoryResult> onCreateLoaderV2(int id, Bundle args) { + if (mExecutorService == null) { + // TODO(b:388130971): Fine tune the size of the thread pool. + mExecutorService = Executors.newFixedThreadPool( + GlobalSearchLoader.MAX_OUTSTANDING_TASK); + } + DocumentStack stack = mState.stack; + RootInfo root = stack.getRoot(); + List<UserId> userIdList = DocumentsApplication.getUserIdManager(mActivity).getUserIds(); + + Duration lastModifiedDelta = stack.isRecents() + ? Duration.ofMillis(RecentsLoader.REJECT_OLDER_THAN) + : null; + int maxResults = (root == null || root.isRecents()) + ? RecentsLoader.MAX_DOCS_FROM_ROOT : MAX_RESULTS; + QueryOptions options = new QueryOptions( + maxResults, lastModifiedDelta, Duration.ofMillis(MAX_SEARCH_TIME_MS), + mState.showHiddenFiles, mState.acceptMimes, mSearchMgr.buildQueryArgs()); + + if (stack.isRecents() || mSearchMgr.isSearching()) { + Log.d(TAG, "Creating search loader V2"); + // For search and recent we create an observer that restart the loader every time + // one of the searched content providers reports a change. + final LockingContentObserver observer = new LockingContentObserver( + mContentLock, AbstractActionHandler.this::loadDocumentsForCurrentStack); + Collection<RootInfo> rootList = new ArrayList<>(); + if (stack.isRecents()) { + // TODO(b:381346575): Pass roots based on user selection. + rootList.addAll(mProviders.getMatchingRootsBlocking(mState).stream().filter( + r -> r.supportsSearch() && r.authority != null + && r.rootId != null).toList()); + } else { + rootList.add(root); + } + return new SearchLoader( + mActivity, + userIdList, + mInjector.fileTypeLookup, + observer, + rootList, + mSearchMgr.getCurrentSearch(), + options, + mState.sortModel, + mExecutorService + ); + } + Log.d(TAG, "Creating folder loader V2"); + // For folder scan we pass the content lock to the loader so that it can register + // an a callback to its internal method that forces a reload of the folder, every + // time the content provider reports a change. + return new FolderLoader( + mActivity, + userIdList, + mInjector.fileTypeLookup, + mContentLock, + root, + stack.peek(), + options, + mState.sortModel + ); + + } + @Override public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) { if (DEBUG) { diff --git a/src/com/android/documentsui/ActionHandler.java b/src/com/android/documentsui/ActionHandler.java index 15124eb33..190f5d960 100644 --- a/src/com/android/documentsui/ActionHandler.java +++ b/src/com/android/documentsui/ActionHandler.java @@ -118,7 +118,7 @@ public interface ActionHandler { void showCreateDirectoryDialog(); - void showInspector(DocumentInfo doc); + void showPreview(DocumentInfo doc); @Nullable DocumentInfo renameDocument(String name, DocumentInfo document); @@ -129,6 +129,12 @@ public interface ActionHandler { boolean openItem(ItemDetails<String> doc, @ViewType int type, @ViewType int fallback); /** + * Similar to openItem but takes DocumentInfo instead of DocumentItemDetails and uses + * VIEW_TYPE_VIEW with no fallback. + */ + void openDocumentViewOnly(DocumentInfo doc); + + /** * This is called when user hovers over a doc for enough time during a drag n' drop, to open a * folder that accepts drop. We should only open a container that's not an archive, since archives * do not accept dropping. diff --git a/src/com/android/documentsui/ActionModeController.java b/src/com/android/documentsui/ActionModeController.java index 1bd4eea3c..931942fca 100644 --- a/src/com/android/documentsui/ActionModeController.java +++ b/src/com/android/documentsui/ActionModeController.java @@ -17,6 +17,7 @@ package com.android.documentsui; import static com.android.documentsui.base.SharedMinimal.DEBUG; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.app.Activity; import android.util.Log; @@ -38,6 +39,8 @@ import com.android.documentsui.ui.MessageBuilder; /** * A controller that listens to selection changes and manages life cycles of action modes. + * TODO(b/379776735): This class (and action mode in general) is no longer in use when the + * use_material3 flag is enabled. Remove the class once the flag is rolled out. */ public class ActionModeController extends SelectionObserver<String> implements ActionMode.Callback, ActionModeAddons { @@ -134,8 +137,12 @@ public class ActionModeController extends SelectionObserver<String> mActivity.getWindow().setTitle(mActivity.getTitle()); // Re-enable TalkBack for the toolbars, as they are no longer covered by action mode. + int[] toolbarIds = + isUseMaterial3FlagEnabled() + ? new int[] {R.id.toolbar} + : new int[] {R.id.toolbar, R.id.roots_toolbar}; mScope.accessibilityImportanceSetter.setAccessibilityImportance( - View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, R.id.toolbar, R.id.roots_toolbar); + View.IMPORTANT_FOR_ACCESSIBILITY_AUTO, toolbarIds); mNavigator.setActionModeActivated(false); } @@ -151,10 +158,13 @@ public class ActionModeController extends SelectionObserver<String> // Hide the toolbars if action mode is enabled, so TalkBack doesn't navigate to // these controls when using linear navigation. + int[] toolbarIds = + isUseMaterial3FlagEnabled() + ? new int[] {R.id.toolbar} + : new int[] {R.id.toolbar, R.id.roots_toolbar}; mScope.accessibilityImportanceSetter.setAccessibilityImportance( View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS, - R.id.toolbar, - R.id.roots_toolbar); + toolbarIds); return true; } diff --git a/src/com/android/documentsui/BaseActivity.java b/src/com/android/documentsui/BaseActivity.java index b3439d585..0b5d96da9 100644 --- a/src/com/android/documentsui/BaseActivity.java +++ b/src/com/android/documentsui/BaseActivity.java @@ -19,7 +19,7 @@ package com.android.documentsui; import static com.android.documentsui.base.Shared.EXTRA_BENCHMARK; import static com.android.documentsui.base.SharedMinimal.DEBUG; import static com.android.documentsui.base.State.MODE_GRID; -import static com.android.documentsui.flags.Flags.useMaterial3; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.content.Context; import android.content.Intent; @@ -79,6 +79,7 @@ import com.android.documentsui.sorting.SortModel; import com.android.modules.utils.build.SdkLevel; import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.button.MaterialButton; import com.google.android.material.color.DynamicColors; import java.util.ArrayList; @@ -183,7 +184,7 @@ public abstract class BaseActivity // in case Activity continuously encounter resource not found exception. getTheme().applyStyle(R.style.DocumentsDefaultTheme, false); - if (useMaterial3() && SdkLevel.isAtLeastS()) { + if (isUseMaterial3FlagEnabled() && SdkLevel.isAtLeastS()) { DynamicColors.applyToActivityIfAvailable(this); } @@ -204,6 +205,16 @@ public abstract class BaseActivity mDrawer = DrawerController.create(this, mInjector.config); Metrics.logActivityLaunch(mState, intent); + if (isUseMaterial3FlagEnabled()) { + View navRailRoots = findViewById(R.id.nav_rail_container_roots); + if (navRailRoots != null) { + // Bind event listener for the burger menu on nav rail. + MaterialButton burgerMenu = findViewById(R.id.nav_rail_burger_menu); + burgerMenu.setOnClickListener(v -> mDrawer.setOpen(true)); + burgerMenu.setOnFocusChangeListener(this::onBurgerMenuFocusChange); + } + } + mProviders = DocumentsApplication.getProvidersCache(this); mDocs = DocumentsAccess.create(this, mState); @@ -358,6 +369,14 @@ public abstract class BaseActivity if (roots != null) { roots.onSelectedUserChanged(); } + if (isUseMaterial3FlagEnabled()) { + final RootsFragment navRailRoots = + RootsFragment.getNavRail(getSupportFragmentManager()); + if (navRailRoots != null) { + navRailRoots.onSelectedUserChanged(); + } + } + if (mState.stack.size() <= 1) { // We do not load cross-profile root if the stack contains two documents. The @@ -378,6 +397,13 @@ public abstract class BaseActivity }); mSortController = SortController.create(this, mState.derivedMode, mState.sortModel); + if (isUseMaterial3FlagEnabled()) { + View previewIconPlaceholder = findViewById(R.id.preview_icon_placeholder); + if (previewIconPlaceholder != null) { + previewIconPlaceholder.setVisibility( + mState.shouldShowPreview() ? View.VISIBLE : View.GONE); + } + } mPreferencesMonitor = new PreferencesMonitor( getApplicationContext().getPackageName(), @@ -393,13 +419,27 @@ public abstract class BaseActivity private NavigationViewManager getNavigationViewManager(Breadcrumb breadcrumb, View profileTabsContainer) { if (mConfigStore.isPrivateSpaceInDocsUIEnabled()) { - return new NavigationViewManager(this, mDrawer, mState, this, breadcrumb, - profileTabsContainer, DocumentsApplication.getUserManagerState(this), - mConfigStore); + return new NavigationViewManager( + this, + mDrawer, + mState, + this, + breadcrumb, + profileTabsContainer, + DocumentsApplication.getUserManagerState(this), + mConfigStore, + mInjector); } - return new NavigationViewManager(this, mDrawer, mState, this, breadcrumb, - profileTabsContainer, DocumentsApplication.getUserIdManager(this), - mConfigStore); + return new NavigationViewManager( + this, + mDrawer, + mState, + this, + breadcrumb, + profileTabsContainer, + DocumentsApplication.getUserIdManager(this), + mConfigStore, + mInjector); } public void onPreferenceChanged(String pref) { @@ -413,14 +453,21 @@ public abstract class BaseActivity protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); - mRootsMonitor = new RootsMonitor<>( - this, - mInjector.actions, - mProviders, - mDocs, - mState, - mSearchManager, - mInjector.actionModeController::finishActionMode); + Runnable finishActionMode = + (isUseMaterial3FlagEnabled()) + ? mNavigator::closeSelectionBar + : mInjector.actionModeController::finishActionMode; + + mRootsMonitor = + new RootsMonitor<>( + this, + mInjector.actions, + mProviders, + mDocs, + mState, + mSearchManager, + finishActionMode); + mRootsMonitor.start(); } @@ -432,6 +479,13 @@ public abstract class BaseActivity @Override public boolean onCreateOptionsMenu(Menu menu) { + if (isUseMaterial3FlagEnabled()) { + // In Material3 the menu is now inflated in the `NavigationViewMenu`. This is currently + // to allow for us to inflate between the action_menu and the activity menu. Once the + // Material 3 flag is removed, the menus will be merged and we can rely on this single + // inflation point. + return super.onCreateOptionsMenu(menu); + } boolean showMenu = super.onCreateOptionsMenu(menu); getMenuInflater().inflate(R.menu.activity, menu); @@ -440,9 +494,10 @@ public abstract class BaseActivity boolean showSearchBar = getResources().getBoolean(R.bool.show_search_bar); mSearchManager.install(menu, fullBarSearch, showSearchBar); + // Remove the subMenu when material3 is launched b/379776735. final ActionMenuView subMenuView = findViewById(R.id.sub_menu); // If size is 0, it means the menu has not inflated and it should only do once. - if (subMenuView.getMenu().size() == 0) { + if (subMenuView != null && subMenuView.getMenu().size() == 0) { subMenuView.setOnMenuItemClickListener(this::onOptionsItemSelected); getMenuInflater().inflate(R.menu.sub_menu, subMenuView.getMenu()); } @@ -454,9 +509,17 @@ public abstract class BaseActivity @CallSuper public boolean onPrepareOptionsMenu(Menu menu) { super.onPrepareOptionsMenu(menu); - mSearchManager.showMenu(mState.stack); - final ActionMenuView subMenuView = findViewById(R.id.sub_menu); - mInjector.menuManager.updateSubMenu(subMenuView.getMenu()); + // Remove the subMenu when material3 is launched b/379776735. + if (isUseMaterial3FlagEnabled()) { + if (mNavigator != null) { + mNavigator.updateActionMenu(); + } + } else { + mSearchManager.showMenu(mState.stack); + final ActionMenuView subMenuView = findViewById(R.id.sub_menu); + mInjector.menuManager.updateSubMenu(subMenuView.getMenu()); + } + return true; } @@ -510,11 +573,16 @@ public abstract class BaseActivity root.setPadding(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0); - View saveContainer = findViewById(R.id.container_save); - saveContainer.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom()); + // When use_material3 flag is ON, no additional bottom gap in full screen mode. + if (!isUseMaterial3FlagEnabled()) { + View saveContainer = findViewById(R.id.container_save); + saveContainer.setPadding( + 0, 0, 0, insets.getSystemWindowInsetBottom()); - View rootsContainer = findViewById(R.id.container_roots); - rootsContainer.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom()); + View rootsContainer = findViewById(R.id.container_roots); + rootsContainer.setPadding( + 0, 0, 0, insets.getSystemWindowInsetBottom()); + } return insets.consumeSystemWindowInsets(); }); @@ -549,7 +617,11 @@ public abstract class BaseActivity return; } - mInjector.actionModeController.finishActionMode(); + if (isUseMaterial3FlagEnabled()) { + mNavigator.closeSelectionBar(); + } else { + mInjector.actionModeController.finishActionMode(); + } mSortController.onViewModeChanged(mState.derivedMode); // Set summary header's visibility. Only recents and downloads root may have summary in @@ -648,6 +720,10 @@ public abstract class BaseActivity mNavigator.update(); } + public final NavigationViewManager getNavigator() { + return mNavigator; + } + @Override public void restoreRootAndDirectory() { // We're trying to restore stuff in document stack from saved instance. If we didn't have a @@ -683,6 +759,13 @@ public abstract class BaseActivity if (roots != null) { roots.onCurrentRootChanged(); } + if (isUseMaterial3FlagEnabled()) { + final RootsFragment navRailRoots = + RootsFragment.getNavRail(getSupportFragmentManager()); + if (navRailRoots != null) { + navRailRoots.onCurrentRootChanged(); + } + } String appName = getString(R.string.files_label); String currentTitle = getTitle() != null ? getTitle().toString() : ""; @@ -759,8 +842,13 @@ public abstract class BaseActivity LocalPreferences.setViewMode(this, getCurrentRoot(), mode); mState.derivedMode = mode; - final ActionMenuView subMenuView = findViewById(R.id.sub_menu); - mInjector.menuManager.updateSubMenu(subMenuView.getMenu()); + // Remove the subMenu when material3 is launched b/379776735. + if (isUseMaterial3FlagEnabled()) { + mInjector.menuManager.updateSubMenu(null); + } else { + final ActionMenuView subMenuView = findViewById(R.id.sub_menu); + mInjector.menuManager.updateSubMenu(subMenuView.getMenu()); + } DirectoryFragment dir = getDirectoryFragment(); if (dir != null) { @@ -793,6 +881,7 @@ public abstract class BaseActivity * @param shouldHideHeader whether to hide header container or not */ public void updateHeader(boolean shouldHideHeader) { + // Remove headContainer when material3 is launched. b/379776735. View headerContainer = findViewById(R.id.header_container); if (headerContainer == null) { updateHeaderTitle(); @@ -840,8 +929,11 @@ public abstract class BaseActivity break; } + // Remove the headerTitle when material3 is launched b/379776735. TextView headerTitle = findViewById(R.id.header_title); - headerTitle.setText(result); + if (headerTitle != null) { + headerTitle.setText(result); + } } private String getHeaderRecentTitle() { @@ -1081,4 +1173,19 @@ public abstract class BaseActivity } setRecentsScreenshotEnabled(!mUserManagerState.areHiddenInQuietModeProfilesPresent()); } + + /** + * When the burger menu is focused, adding a focus ring indicator using Stroke. + * TODO(b/381957932): Remove this once Material Button supports focus ring. + */ + private void onBurgerMenuFocusChange(View v, boolean hasFocus) { + MaterialButton burgerMenu = (MaterialButton) v; + if (hasFocus) { + final int focusRingWidth = getResources() + .getDimensionPixelSize(R.dimen.focus_ring_width); + burgerMenu.setStrokeWidth(focusRingWidth); + } else { + burgerMenu.setStrokeWidth(0); + } + } } diff --git a/src/com/android/documentsui/DrawerController.java b/src/com/android/documentsui/DrawerController.java index 56b3a879f..cc90c9869 100644 --- a/src/com/android/documentsui/DrawerController.java +++ b/src/com/android/documentsui/DrawerController.java @@ -17,6 +17,7 @@ package com.android.documentsui; import static com.android.documentsui.base.SharedMinimal.DEBUG; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.app.Activity; import android.util.Log; @@ -59,6 +60,8 @@ public abstract class DrawerController implements DrawerListener { } View drawer = activity.findViewById(R.id.drawer_roots); + // This will be null when use_material3 flag is ON, we will check the flag when it's used in + // RuntimeDrawerController. Toolbar toolbar = (Toolbar) activity.findViewById(R.id.roots_toolbar); drawer.getLayoutParams().width = calculateDrawerWidth(activity); @@ -124,7 +127,10 @@ public abstract class DrawerController implements DrawerListener { if (activityConfig.dragAndDropEnabled()) { View edge = layout.findViewById(R.id.drawer_edge); - edge.setOnDragListener(new ItemDragListener<>(this, SPRING_TIMEOUT)); + // nav_rail_layout also uses DrawerLayout, but it doesn't have drawer edge. + if (edge != null) { + edge.setOnDragListener(new ItemDragListener<>(this, SPRING_TIMEOUT)); + } } } @@ -202,7 +208,9 @@ public abstract class DrawerController implements DrawerListener { @Override void setTitle(String title) { - mToolbar.setTitle(title); + if (!isUseMaterial3FlagEnabled()) { + mToolbar.setTitle(title); + } } @Override diff --git a/src/com/android/documentsui/Injector.java b/src/com/android/documentsui/Injector.java index 82cbfcccb..6b68ba1f1 100644 --- a/src/com/android/documentsui/Injector.java +++ b/src/com/android/documentsui/Injector.java @@ -15,6 +15,8 @@ */ package com.android.documentsui; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.RetentionPolicy.SOURCE; @@ -127,6 +129,9 @@ public class Injector<T extends ActionHandler> { public final ActionModeController getActionModeController( SelectionDetails selectionDetails, EventHandler<MenuItem> menuItemClicker) { + if (isUseMaterial3FlagEnabled()) { + return null; + } return actionModeController.reset(selectionDetails, menuItemClicker); } diff --git a/src/com/android/documentsui/MenuManager.java b/src/com/android/documentsui/MenuManager.java index f46ffe482..5f17d7e02 100644 --- a/src/com/android/documentsui/MenuManager.java +++ b/src/com/android/documentsui/MenuManager.java @@ -16,6 +16,9 @@ package com.android.documentsui; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; +import static com.android.documentsui.util.FlagUtils.isZipNgFlagEnabled; + import android.view.KeyboardShortcutGroup; import android.view.Menu; import android.view.MenuInflater; @@ -25,6 +28,7 @@ import android.view.View; import androidx.annotation.VisibleForTesting; import androidx.fragment.app.Fragment; +import com.android.documentsui.archives.ArchivesProvider; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.Menus; import com.android.documentsui.base.RootInfo; @@ -89,6 +93,9 @@ public abstract class MenuManager { return; } updateCreateDir(mOptionMenu.findItem(R.id.option_menu_create_dir)); + if (isZipNgFlagEnabled()) { + updateExtractAll(mOptionMenu.findItem(R.id.option_menu_extract_all)); + } updateSettings(mOptionMenu.findItem(R.id.option_menu_settings)); updateSelectAll(mOptionMenu.findItem(R.id.option_menu_select_all)); updateNewWindow(mOptionMenu.findItem(R.id.option_menu_new_window)); @@ -98,12 +105,25 @@ public abstract class MenuManager { updateLauncher(mOptionMenu.findItem(R.id.option_menu_launcher)); updateShowHiddenFiles(mOptionMenu.findItem(R.id.option_menu_show_hidden_files)); + if (isUseMaterial3FlagEnabled()) { + updateModePicker(mOptionMenu.findItem(R.id.sub_menu_grid), + mOptionMenu.findItem(R.id.sub_menu_list)); + } + Menus.disableHiddenItems(mOptionMenu); mSearchManager.updateMenu(); } public void updateSubMenu(Menu menu) { + // Remove the subMenu when material3 is launched b/379776735. + if (isUseMaterial3FlagEnabled()) { + menu = mOptionMenu; + if (menu == null) { + return; + } + } updateModePicker(menu.findItem(R.id.sub_menu_grid), menu.findItem(R.id.sub_menu_list)); + } public void updateModel(Model model) {} @@ -214,10 +234,7 @@ public abstract class MenuManager { Menus.setEnabledAndVisible(inspect, selectionDetails.size() == 1); - final MenuItem compress = menu.findItem(R.id.dir_menu_compress); - if (compress != null) { - updateCompress(compress, selectionDetails); - } + updateCompress(menu.findItem(R.id.dir_menu_compress), selectionDetails); } /** @@ -382,6 +399,10 @@ public abstract class MenuManager { Menus.setEnabledAndVisible(launcher, false); } + protected void updateExtractAll(MenuItem it) { + Menus.setEnabledAndVisible(it, false); + } + protected abstract void updateSelectAll(MenuItem selectAll); protected abstract void updateSelectAll(MenuItem selectAll, SelectionDetails selectionDetails); protected abstract void updateDeselectAll( @@ -412,7 +433,7 @@ public abstract class MenuManager { boolean canExtract(); - boolean canOpenWith(); + boolean canOpen(); boolean canViewInOwner(); } @@ -440,6 +461,12 @@ public abstract class MenuManager { return mActivity.isInRecents(); } + /** Is the current directory showing the contents of an archive? */ + public boolean isInArchive() { + final DocumentInfo dir = mActivity.getCurrentDirectory(); + return dir != null && ArchivesProvider.AUTHORITY.equals(dir.authority); + } + public boolean canCreateDirectory() { return mActivity.canCreateDirectory(); } diff --git a/src/com/android/documentsui/MultiRootDocumentsLoader.java b/src/com/android/documentsui/MultiRootDocumentsLoader.java index db78daa48..1213a6711 100644 --- a/src/com/android/documentsui/MultiRootDocumentsLoader.java +++ b/src/com/android/documentsui/MultiRootDocumentsLoader.java @@ -71,7 +71,7 @@ public abstract class MultiRootDocumentsLoader extends AsyncTaskLoader<Directory // previously returned cursors for filtering/sorting; this currently races // with the UI thread. - private static final int MAX_OUTSTANDING_TASK = 4; + public static final int MAX_OUTSTANDING_TASK = 4; private static final int MAX_OUTSTANDING_TASK_SVELTE = 2; /** diff --git a/src/com/android/documentsui/NavigationViewManager.java b/src/com/android/documentsui/NavigationViewManager.java index c376c86db..86b5e517f 100644 --- a/src/com/android/documentsui/NavigationViewManager.java +++ b/src/com/android/documentsui/NavigationViewManager.java @@ -17,6 +17,7 @@ package com.android.documentsui; import static com.android.documentsui.base.SharedMinimal.VERBOSE; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.content.res.Resources; import android.content.res.TypedArray; @@ -24,6 +25,7 @@ import android.graphics.Outline; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.util.Log; +import android.view.MenuItem; import android.view.View; import android.view.ViewOutlineProvider; import android.view.Window; @@ -34,7 +36,10 @@ import androidx.annotation.ColorRes; import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; +import androidx.recyclerview.selection.SelectionTracker; +import com.android.documentsui.Injector.Injected; +import com.android.documentsui.base.EventHandler; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.State; import com.android.documentsui.base.UserId; @@ -47,10 +52,9 @@ import com.google.android.material.appbar.CollapsingToolbarLayout; import java.util.function.IntConsumer; -/** - * A facade over the portions of the app and drawer toolbars. - */ -public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListener { +/** A facade over the portions of the app and drawer toolbars. */ +public class NavigationViewManager extends SelectionTracker.SelectionObserver<String> + implements AppBarLayout.OnOffsetChangedListener { private static final String TAG = "NavigationViewManager"; @@ -69,10 +73,11 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen private final ViewOutlineProvider mSearchBarOutlineProvider; private final boolean mShowSearchBar; private final ConfigStore mConfigStore; - + @Injected private final Injector<?> mInjector; private boolean mIsActionModeActivated = false; - @ColorRes - private int mDefaultStatusBarColorResId; + @ColorRes private int mDefaultStatusBarColorResId; + private MenuManager.SelectionDetails mSelectionDetails; + private EventHandler<MenuItem> mActionMenuItemClicker; public NavigationViewManager( BaseActivity activity, @@ -82,9 +87,19 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen Breadcrumb breadcrumb, View tabLayoutContainer, UserIdManager userIdManager, - ConfigStore configStore) { - this(activity, drawer, state, env, breadcrumb, tabLayoutContainer, userIdManager, null, - configStore); + ConfigStore configStore, + Injector injector) { + this( + activity, + drawer, + state, + env, + breadcrumb, + tabLayoutContainer, + userIdManager, + null, + configStore, + injector); } public NavigationViewManager( @@ -95,9 +110,19 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen Breadcrumb breadcrumb, View tabLayoutContainer, UserManagerState userManagerState, - ConfigStore configStore) { - this(activity, drawer, state, env, breadcrumb, tabLayoutContainer, null, userManagerState, - configStore); + ConfigStore configStore, + Injector injector) { + this( + activity, + drawer, + state, + env, + breadcrumb, + tabLayoutContainer, + null, + userManagerState, + configStore, + injector); } public NavigationViewManager( @@ -109,7 +134,8 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen View tabLayoutContainer, UserIdManager userIdManager, UserManagerState userManagerState, - ConfigStore configStore) { + ConfigStore configStore, + Injector injector) { mActivity = activity; mToolbar = activity.findViewById(R.id.toolbar); @@ -120,6 +146,7 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen mBreadcrumb = breadcrumb; mBreadcrumb.setup(env, state, this::onNavigationItemSelected); mConfigStore = configStore; + mInjector = injector; mProfileTabs = getProfileTabs(tabLayoutContainer, userIdManager, userManagerState, activity); @@ -130,6 +157,15 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen onNavigationIconClicked(); } }); + if (isUseMaterial3FlagEnabled()) { + mToolbar.setOnMenuItemClickListener( + new Toolbar.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem menuItem) { + return onToolbarMenuItemClicked(menuItem); + } + }); + } mSearchBarView = activity.findViewById(R.id.searchbar_title); mCollapsingBarLayout = activity.findViewById(R.id.collapsing_toolbar); mDefaultActionBarBackground = mToolbar.getBackground(); @@ -225,11 +261,21 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen } private void onNavigationIconClicked() { - if (mDrawer.isPresent()) { + if (isUseMaterial3FlagEnabled() && inSelectionMode()) { + closeSelectionBar(); + } else if (mDrawer.isPresent()) { mDrawer.setOpen(true); } } + private boolean onToolbarMenuItemClicked(MenuItem menuItem) { + if (inSelectionMode()) { + mActionMenuItemClicker.accept(menuItem); + return true; + } + return mActivity.onOptionsItemSelected(menuItem); + } + void onNavigationItemSelected(int position) { boolean changed = false; while (mState.stack.size() > position + 1) { @@ -264,22 +310,107 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen mDrawer.setTitle(mEnv.getDrawerTitle()); - mToolbar.setNavigationIcon(getActionBarIcon()); - mToolbar.setNavigationContentDescription(R.string.drawer_open); + boolean showBurgerMenuOnToolbar = true; + if (isUseMaterial3FlagEnabled()) { + View navRailRoots = mActivity.findViewById(R.id.nav_rail_container_roots); + if (navRailRoots != null) { + // If nav rail exists, burger menu will show on the nav rail instead. + showBurgerMenuOnToolbar = false; + } + } + + if (showBurgerMenuOnToolbar) { + mToolbar.setNavigationIcon(getActionBarIcon()); + mToolbar.setNavigationContentDescription(R.string.drawer_open); + } else { + mToolbar.setNavigationIcon(null); + mToolbar.setNavigationContentDescription(null); + } if (shouldShowSearchBar()) { mBreadcrumb.show(false); mToolbar.setTitle(null); mSearchBarView.setVisibility(View.VISIBLE); - } else { - mSearchBarView.setVisibility(View.GONE); - String title = mState.stack.size() <= 1 - ? mEnv.getCurrentRoot().title : mState.stack.getTitle(); - if (VERBOSE) Log.v(TAG, "New toolbar title is: " + title); - mToolbar.setTitle(title); - mBreadcrumb.show(true); - mBreadcrumb.postUpdate(); + return; } + + mSearchBarView.setVisibility(View.GONE); + + if (isUseMaterial3FlagEnabled()) { + updateActionMenu(); + if (inSelectionMode()) { + final int quantity = mInjector.selectionMgr.getSelection().size(); + final String title = + mToolbar.getContext() + .getResources() + .getQuantityString(R.plurals.elements_selected, quantity, quantity); + mToolbar.setTitle(title); + mActivity.getWindow().setTitle(title); + mToolbar.setNavigationIcon(R.drawable.ic_cancel); + mToolbar.setNavigationContentDescription(android.R.string.cancel); + return; + } + } + + String title = + mState.stack.size() <= 1 ? mEnv.getCurrentRoot().title : mState.stack.getTitle(); + if (VERBOSE) Log.v(TAG, "New toolbar title is: " + title); + mToolbar.setTitle(title); + mBreadcrumb.show(true); + mBreadcrumb.postUpdate(); + } + + @Override + public void onSelectionChanged() { + update(); + } + + /** Identifies if the `NavigationViewManager` is in selection mode or not. */ + public boolean inSelectionMode() { + return mInjector != null + && mInjector.selectionMgr != null + && mInjector.selectionMgr.hasSelection(); + } + + private boolean hasActionMenu() { + return mToolbar.getMenu().findItem(R.id.action_menu_open_with) != null; + } + + /** Updates the action menu based on whether a selection is currently being made or not. */ + public void updateActionMenu() { + // For the first start up of the application, the menu might not exist at all but we also + // don't want to inflate the menu multiple times. So along with checking if the expected + // menu is already inflated, validate that a menu exists at all as well. + boolean isMenuInflated = mToolbar.getMenu() != null && mToolbar.getMenu().size() > 0; + if (inSelectionMode()) { + if (!isMenuInflated || !hasActionMenu()) { + mToolbar.getMenu().clear(); + mToolbar.inflateMenu(R.menu.action_mode_menu); + mToolbar.invalidateMenu(); + } + mInjector.menuManager.updateActionMenu(mToolbar.getMenu(), mSelectionDetails); + return; + } + + if (!isMenuInflated || hasActionMenu()) { + mToolbar.getMenu().clear(); + mToolbar.inflateMenu(R.menu.activity); + mToolbar.invalidateMenu(); + boolean fullBarSearch = + mActivity.getResources().getBoolean(R.bool.full_bar_search_view); + boolean showSearchBar = mActivity.getResources().getBoolean(R.bool.show_search_bar); + mInjector.searchManager.install(mToolbar.getMenu(), fullBarSearch, showSearchBar); + } + mInjector.menuManager.updateOptionMenu(mToolbar.getMenu()); + mInjector.searchManager.showMenu(mState.stack); + } + + /** Everytime a selection is made, update the selection. */ + public void updateSelection( + MenuManager.SelectionDetails selectionDetails, + EventHandler<MenuItem> actionMenuItemClicker) { + mSelectionDetails = selectionDetails; + mActionMenuItemClicker = actionMenuItemClicker; } private void updateScrollFlag() { @@ -361,6 +492,11 @@ public class NavigationViewManager implements AppBarLayout.OnOffsetChangedListen mDrawer.setOpen(open); } + /** Helper method to close the selection bar. */ + public void closeSelectionBar() { + mInjector.selectionMgr.clearSelection(); + } + interface Breadcrumb { void setup(Environment env, State state, IntConsumer listener); diff --git a/src/com/android/documentsui/ProfileTabs.java b/src/com/android/documentsui/ProfileTabs.java index df525d527..5aacc22b0 100644 --- a/src/com/android/documentsui/ProfileTabs.java +++ b/src/com/android/documentsui/ProfileTabs.java @@ -20,6 +20,7 @@ import static androidx.core.util.Preconditions.checkNotNull; import static com.android.documentsui.DevicePolicyResources.Strings.PERSONAL_TAB; import static com.android.documentsui.DevicePolicyResources.Strings.WORK_TAB; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.app.admin.DevicePolicyManager; import android.os.Build; @@ -155,7 +156,14 @@ public class ProfileTabs implements ProfileTabsAddons { (ViewGroup.MarginLayoutParams) tab.getLayoutParams(); int tabMarginSide = (int) mTabsContainer.getContext().getResources() .getDimension(R.dimen.profile_tab_margin_side); - marginLayoutParams.setMargins(tabMarginSide, 0, tabMarginSide, 0); + if (isUseMaterial3FlagEnabled()) { + // M3 uses the margin value as the right margin, except for the last child. + if (i != mTabs.getTabCount() - 1) { + marginLayoutParams.setMargins(0, 0, tabMarginSide, 0); + } + } else { + marginLayoutParams.setMargins(tabMarginSide, 0, tabMarginSide, 0); + } int tabHeightInDp = (int) mTabsContainer.getContext().getResources() .getDimension(R.dimen.tab_height); tab.getLayoutParams().height = tabHeightInDp; diff --git a/src/com/android/documentsui/RecentsLoader.java b/src/com/android/documentsui/RecentsLoader.java index b3cfa0180..9a3e06fba 100644 --- a/src/com/android/documentsui/RecentsLoader.java +++ b/src/com/android/documentsui/RecentsLoader.java @@ -37,13 +37,13 @@ public class RecentsLoader extends MultiRootDocumentsLoader { private static final String TAG = "RecentsLoader"; /** Ignore documents older than this age. */ - private static final long REJECT_OLDER_THAN = 45 * DateUtils.DAY_IN_MILLIS; + public static final long REJECT_OLDER_THAN = 45 * DateUtils.DAY_IN_MILLIS; - /** MIME types that should always be excluded from recents. */ + /** MIME types that should always be excluded from the Recents view. */ private static final String[] REJECT_MIMES = new String[]{Document.MIME_TYPE_DIR}; /** Maximum documents from a single root. */ - private static final int MAX_DOCS_FROM_ROOT = 64; + public static final int MAX_DOCS_FROM_ROOT = 64; private final UserId mUserId; diff --git a/src/com/android/documentsui/UserManagerState.java b/src/com/android/documentsui/UserManagerState.java index b1c1a0d59..d2ddae615 100644 --- a/src/com/android/documentsui/UserManagerState.java +++ b/src/com/android/documentsui/UserManagerState.java @@ -33,6 +33,7 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; import android.content.pm.UserProperties; import android.graphics.drawable.Drawable; import android.os.Build; @@ -42,16 +43,17 @@ import android.util.Log; import androidx.annotation.GuardedBy; import androidx.annotation.RequiresApi; +import androidx.annotation.RequiresPermission; import androidx.annotation.VisibleForTesting; import com.android.documentsui.base.Features; import com.android.documentsui.base.UserId; -import com.android.documentsui.util.CrossProfileUtils; import com.android.documentsui.util.VersionUtils; import com.android.modules.utils.build.SdkLevel; import com.google.common.base.Objects; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -62,26 +64,23 @@ public interface UserManagerState { /** * Returns the {@link UserId} of each profile which should be queried for documents. This will - * always - * include {@link UserId#CURRENT_USER}. + * always include {@link UserId#CURRENT_USER}. */ List<UserId> getUserIds(); - /** - * Returns mapping between the {@link UserId} and the label for the profile - */ + /** Returns mapping between the {@link UserId} and the label for the profile */ Map<UserId, String> getUserIdToLabelMap(); /** * Returns mapping between the {@link UserId} and the drawable badge for the profile * - * returns {@code null} for non-profile userId + * <p>returns {@code null} for non-profile userId */ Map<UserId, Drawable> getUserIdToBadgeMap(); /** - * Returns a map of {@link UserId} to boolean value indicating whether - * the {@link UserId}.CURRENT_USER can forward {@link Intent} to that {@link UserId} + * Returns a map of {@link UserId} to boolean value indicating whether the {@link + * UserId}.CURRENT_USER can forward {@link Intent} to that {@link UserId} */ Map<UserId, Boolean> getCanForwardToProfileIdMap(Intent intent); @@ -96,25 +95,19 @@ public interface UserManagerState { */ void onProfileActionStatusChange(String action, UserId userId); - /** - * Sets the intent that triggered the launch of the DocsUI - */ + /** Sets the intent that triggered the launch of the DocsUI */ void setCurrentStateIntent(Intent intent); /** Returns true if there are hidden profiles */ boolean areHiddenInQuietModeProfilesPresent(); - /** - * Creates an implementation of {@link UserManagerState}. - */ + /** Creates an implementation of {@link UserManagerState}. */ // TODO: b/314746383 Make this class a singleton static UserManagerState create(Context context) { return new RuntimeUserManagerState(context); } - /** - * Implementation of {@link UserManagerState} - */ + /** Implementation of {@link UserManagerState} */ final class RuntimeUserManagerState implements UserManagerState { private static final String TAG = "UserManagerState"; @@ -123,58 +116,63 @@ public interface UserManagerState { private final boolean mIsDeviceSupported; private final UserManager mUserManager; private final ConfigStore mConfigStore; + /** * List of all the {@link UserId} that have the {@link UserProperties.ShowInSharingSurfaces} * set as `SHOW_IN_SHARING_SURFACES_SEPARATE` OR it is a system/personal user */ @GuardedBy("mUserIds") private final List<UserId> mUserIds = new ArrayList<>(); - /** - * Mapping between the {@link UserId} to the corresponding profile label - */ + + /** Mapping between the {@link UserId} to the corresponding profile label */ @GuardedBy("mUserIdToLabelMap") private final Map<UserId, String> mUserIdToLabelMap = new HashMap<>(); - /** - * Mapping between the {@link UserId} to the corresponding profile badge - */ + + /** Mapping between the {@link UserId} to the corresponding profile badge */ @GuardedBy("mUserIdToBadgeMap") private final Map<UserId, Drawable> mUserIdToBadgeMap = new HashMap<>(); + /** * Map containing {@link UserId}, other than that of the current user, as key and boolean * denoting whether it is accessible by the current user or not as value */ - @GuardedBy("mCanFrowardToProfileIdMap") - private final Map<UserId, Boolean> mCanFrowardToProfileIdMap = new HashMap<>(); + @GuardedBy("mCanForwardToProfileIdMap") + private final Map<UserId, Boolean> mCanForwardToProfileIdMap = new HashMap<>(); private Intent mCurrentStateIntent; - private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - synchronized (mUserIds) { - mUserIds.clear(); - } - synchronized (mUserIdToLabelMap) { - mUserIdToLabelMap.clear(); - } - synchronized (mUserIdToBadgeMap) { - mUserIdToBadgeMap.clear(); - } - synchronized (mCanFrowardToProfileIdMap) { - mCanFrowardToProfileIdMap.clear(); - } - } - }; - + private final BroadcastReceiver mIntentReceiver = + new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + synchronized (mUserIds) { + mUserIds.clear(); + } + synchronized (mUserIdToLabelMap) { + mUserIdToLabelMap.clear(); + } + synchronized (mUserIdToBadgeMap) { + mUserIdToBadgeMap.clear(); + } + synchronized (mCanForwardToProfileIdMap) { + mCanForwardToProfileIdMap.clear(); + } + } + }; private RuntimeUserManagerState(Context context) { - this(context, UserId.CURRENT_USER, + this( + context, + UserId.CURRENT_USER, Features.CROSS_PROFILE_TABS && isDeviceSupported(context), DocumentsApplication.getConfigStore()); } @VisibleForTesting - RuntimeUserManagerState(Context context, UserId currentUser, boolean isDeviceSupported, + RuntimeUserManagerState( + Context context, + UserId currentUser, + boolean isDeviceSupported, ConfigStore configStore) { mContext = context.getApplicationContext(); mCurrentUser = checkNotNull(currentUser); @@ -224,11 +222,11 @@ public interface UserManagerState { @Override public Map<UserId, Boolean> getCanForwardToProfileIdMap(Intent intent) { - synchronized (mCanFrowardToProfileIdMap) { - if (mCanFrowardToProfileIdMap.isEmpty()) { + synchronized (mCanForwardToProfileIdMap) { + if (mCanForwardToProfileIdMap.isEmpty()) { getCanForwardToProfileIdMapInternal(intent); } - return mCanFrowardToProfileIdMap; + return mCanForwardToProfileIdMap; } } @@ -236,8 +234,8 @@ public interface UserManagerState { @SuppressLint("NewApi") public void onProfileActionStatusChange(String action, UserId userId) { if (!SdkLevel.isAtLeastV()) return; - UserProperties userProperties = mUserManager.getUserProperties( - UserHandle.of(userId.getIdentifier())); + UserProperties userProperties = + mUserManager.getUserProperties(UserHandle.of(userId.getIdentifier())); if (userProperties.getShowInQuietMode() != UserProperties.SHOW_IN_QUIET_MODE_HIDDEN) { return; } @@ -263,19 +261,37 @@ public interface UserManagerState { mUserIdToBadgeMap.put(userId, getProfileBadge(userId)); } } - synchronized (mCanFrowardToProfileIdMap) { - if (!mCanFrowardToProfileIdMap.containsKey(userId)) { - if (userId.getIdentifier() == ActivityManager.getCurrentUser() - || isCrossProfileContentSharingStrategyDelegatedFromParent( - UserHandle.of(userId.getIdentifier())) - || CrossProfileUtils.getCrossProfileResolveInfo(mCurrentUser, - mContext.getPackageManager(), mCurrentStateIntent, mContext, - mConfigStore.isPrivateSpaceInDocsUIEnabled()) != null) { - mCanFrowardToProfileIdMap.put(userId, true); + synchronized (mCanForwardToProfileIdMap) { + if (!mCanForwardToProfileIdMap.containsKey(userId)) { + + UserHandle handle = UserHandle.of(userId.getIdentifier()); + + // Decide if to use the parent's access, or this handle's access. + if (isCrossProfileContentSharingStrategyDelegatedFromParent(handle)) { + UserHandle parentHandle = mUserManager.getProfileParent(handle); + // Couldn't resolve parent to check access, so fail closed. + if (parentHandle == null) { + mCanForwardToProfileIdMap.put(userId, false); + } else if (mCurrentUser.getIdentifier() + == parentHandle.getIdentifier()) { + // Check if the parent is the current user, if so this profile + // is also accessible. + mCanForwardToProfileIdMap.put(userId, true); + + } else { + UserId parent = UserId.of(parentHandle); + mCanForwardToProfileIdMap.put( + userId, + doesCrossProfileForwardingActivityExistForUser( + mCurrentStateIntent, parent)); + } } else { - mCanFrowardToProfileIdMap.put(userId, false); + // Update the profile map for this profile. + mCanForwardToProfileIdMap.put( + userId, + doesCrossProfileForwardingActivityExistForUser( + mCurrentStateIntent, userId)); } - } } } else { @@ -343,7 +359,7 @@ public interface UserManagerState { // returned should satisfy both the following conditions: // 1. It has user property SHOW_IN_SHARING_SURFACES_SEPARATE // 2. Quite mode is not enabled, if it is enabled then the profile's user - // property is not SHOW_IN_QUIET_MODE_HIDDEN + // property is not SHOW_IN_QUIET_MODE_HIDDEN if (isProfileAllowed(userHandle)) { result.add(UserId.of(userHandle)); } @@ -354,16 +370,77 @@ public interface UserManagerState { } } + /** + * Checks if a package is installed for a given user. + * + * @param userHandle The ID of the user. + * @return {@code true} if the package is installed for the user, {@code false} otherwise. + */ + @RequiresPermission( + anyOf = { + "android.permission.MANAGE_USERS", + "android.permission.INTERACT_ACROSS_USERS" + }) + private boolean isPackageInstalledForUser(UserHandle userHandle) { + String packageName = mContext.getPackageName(); + try { + Context userPackageContext = + mContext.createPackageContextAsUser( + mContext.getPackageName(), 0 /* flags */, userHandle); + return userPackageContext != null; + } catch (PackageManager.NameNotFoundException e) { + Log.w(TAG, "Package " + packageName + " not found for user " + userHandle); + return false; + } + } + + /** + * Checks if quiet mode is enabled for a given user. + * + * @param userHandle The UserHandle of the profile to check. + * @return {@code true} if quiet mode is enabled, {@code false} otherwise. + */ + private boolean isQuietModeEnabledForUser(UserHandle userHandle) { + return UserId.of(userHandle.getIdentifier()).isQuietModeEnabled(mContext); + } + + /** + * Checks if a profile should be allowed, taking into account quiet mode and package + * installation. + * + * @param userHandle The UserHandle of the profile to check. + * @return {@code true} if the profile should be allowed, {@code false} otherwise. + */ @SuppressLint("NewApi") + @RequiresPermission( + anyOf = { + "android.permission.MANAGE_USERS", + "android.permission.INTERACT_ACROSS_USERS" + }) private boolean isProfileAllowed(UserHandle userHandle) { - final UserProperties userProperties = - mUserManager.getUserProperties(userHandle); + final UserProperties userProperties = mUserManager.getUserProperties(userHandle); + + // 1. Check if the package is installed for the user + if (!isPackageInstalledForUser(userHandle)) { + Log.w( + TAG, + "Package " + + mContext.getPackageName() + + " is not installed for user " + + userHandle); + return false; + } + + // 2. Check user properties and quiet mode if (userProperties.getShowInSharingSurfaces() == UserProperties.SHOW_IN_SHARING_SURFACES_SEPARATE) { - return !UserId.of(userHandle).isQuietModeEnabled(mContext) + // Return true if profile is not in quiet mode or if it is in quiet mode + // then its user properties do not require it to be hidden + return !isQuietModeEnabledForUser(userHandle) || userProperties.getShowInQuietMode() - != UserProperties.SHOW_IN_QUIET_MODE_HIDDEN; + != UserProperties.SHOW_IN_QUIET_MODE_HIDDEN; } + return false; } @@ -384,9 +461,12 @@ public interface UserManagerState { result.add(0, systemUser); } else { if (DEBUG) { - Log.w(TAG, "The current user " + UserId.CURRENT_USER - + " is neither system nor managed user. has system user: " - + (systemUser != null)); + Log.w( + TAG, + "The current user " + + UserId.CURRENT_USER + + " is neither system nor managed user. has system user: " + + (systemUser != null)); } } } @@ -422,13 +502,13 @@ public interface UserManagerState { for (UserId userId : userIds) { if (mUserManager.isManagedProfile(userId.getIdentifier())) { synchronized (mUserIdToLabelMap) { - mUserIdToLabelMap.put(userId, - getEnterpriseString(WORK_TAB, R.string.work_tab)); + mUserIdToLabelMap.put( + userId, getEnterpriseString(WORK_TAB, R.string.work_tab)); } } else { synchronized (mUserIdToLabelMap) { - mUserIdToLabelMap.put(userId, - getEnterpriseString(PERSONAL_TAB, R.string.personal_tab)); + mUserIdToLabelMap.put( + userId, getEnterpriseString(PERSONAL_TAB, R.string.personal_tab)); } } } @@ -440,8 +520,9 @@ public interface UserManagerState { return getEnterpriseString(PERSONAL_TAB, R.string.personal_tab); } try { - Context userContext = mContext.createContextAsUser( - UserHandle.of(userId.getIdentifier()), 0 /* flags */); + Context userContext = + mContext.createContextAsUser( + UserHandle.of(userId.getIdentifier()), 0 /* flags */); UserManager userManagerAsUser = userContext.getSystemService(UserManager.class); if (userManagerAsUser == null) { Log.e(TAG, "cannot obtain user manager"); @@ -469,9 +550,8 @@ public interface UserManagerState { Log.e(TAG, "can not get device policy manager"); return mContext.getString(defaultStringId); } - return dpm.getResources().getString( - updatableStringId, - () -> mContext.getString(defaultStringId)); + return dpm.getResources() + .getString(updatableStringId, () -> mContext.getString(defaultStringId)); } private void getUserIdToBadgeMapInternal() { @@ -506,8 +586,10 @@ public interface UserManagerState { for (UserId userId : userIds) { if (mUserManager.isManagedProfile(userId.getIdentifier())) { synchronized (mUserIdToBadgeMap) { - mUserIdToBadgeMap.put(userId, - SdkLevel.isAtLeastT() ? getWorkProfileBadge() + mUserIdToBadgeMap.put( + userId, + SdkLevel.isAtLeastT() + ? getWorkProfileBadge() : mContext.getDrawable(R.drawable.ic_briefcase)); } } @@ -520,8 +602,9 @@ public interface UserManagerState { return null; } try { - Context userContext = mContext.createContextAsUser( - UserHandle.of(userId.getIdentifier()), 0 /* flags */); + Context userContext = + mContext.createContextAsUser( + UserHandle.of(userId.getIdentifier()), 0 /* flags */); UserManager userManagerAsUser = userContext.getSystemService(UserManager.class); if (userManagerAsUser == null) { Log.e(TAG, "cannot obtain user manager"); @@ -537,86 +620,142 @@ public interface UserManagerState { @RequiresApi(Build.VERSION_CODES.TIRAMISU) private Drawable getWorkProfileBadge() { DevicePolicyManager dpm = mContext.getSystemService(DevicePolicyManager.class); - Drawable drawable = dpm.getResources().getDrawable(WORK_PROFILE_ICON, SOLID_COLORED, - () -> - mContext.getDrawable(R.drawable.ic_briefcase)); + Drawable drawable = + dpm.getResources() + .getDrawable( + WORK_PROFILE_ICON, + SOLID_COLORED, + () -> mContext.getDrawable(R.drawable.ic_briefcase)); return drawable; } + /** + * Updates Cross Profile access for all UserProfiles in {@code getUserIds()} + * + * <p>This method looks at a variety of situations for each Profile and decides if the + * profile's content is accessible by the current process owner user id. + * + * <ol> + * <li>UserProperties attributes for CrossProfileDelegation are checked first. When the + * profile delegates to the parent profile, the parent's access is used. + * <li>{@link CrossProfileIntentForwardingActivity}s are resolved via the process owner's + * PackageManager, and are considered when evaluating cross profile to the target + * profile. + * </ol> + * + * <p>In the event none of the above checks succeeds, the profile is considered to be + * inaccessible to the current process user. + * + * @param intent The intent Photopicker is currently running under, for + * CrossProfileForwardActivity checking. + */ private void getCanForwardToProfileIdMapInternal(Intent intent) { - // Versions less than V will not have the user properties required to determine whether - // cross profile check is delegated from parent or not - if (!SdkLevel.isAtLeastV()) { - getCanForwardToProfileIdMapPreV(intent); - return; - } - if (mUserManager == null) { - Log.e(TAG, "can not get user manager"); - return; - } - List<UserId> parentOrDelegatedFromParent = new ArrayList<>(); - List<UserId> canForwardToProfileIds = new ArrayList<>(); - List<UserId> noDelegation = new ArrayList<>(); + Map<UserId, Boolean> profileIsAccessibleToProcessOwner = new HashMap<>(); - List<UserId> userIds = getUserIds(); - for (UserId userId : userIds) { - final UserHandle userHandle = UserHandle.of(userId.getIdentifier()); - // Parent (personal) profile and all the child profiles that delegate cross profile - // content sharing check to parent can share among each other - if (userId.getIdentifier() == ActivityManager.getCurrentUser() - || isCrossProfileContentSharingStrategyDelegatedFromParent(userHandle)) { - parentOrDelegatedFromParent.add(userId); - } else { - noDelegation.add(userId); + List<UserId> delegatedFromParent = new ArrayList<>(); + + for (UserId userId : getUserIds()) { + + // Early exit, self is always accessible. + if (userId.getIdentifier() == mCurrentUser.getIdentifier()) { + profileIsAccessibleToProcessOwner.put(userId, true); + continue; } - } - if (noDelegation.size() > 1) { - Log.e(TAG, "There cannot be more than one profile delegating cross profile " - + "content sharing check from self."); - } - - /* - * Cross profile resolve info need to be checked in the following 2 cases: - * 1. current user is either parent or delegates check to parent and the target user - * does not delegate to parent - * 2. current user does not delegate check to the parent and the target user is the - * parent profile - */ - UserId needToCheck = null; - if (parentOrDelegatedFromParent.contains(mCurrentUser) - && !noDelegation.isEmpty()) { - needToCheck = noDelegation.get(0); - } else if (mCurrentUser.getIdentifier() != ActivityManager.getCurrentUser()) { - final UserHandle parentProfile = mUserManager.getProfileParent( - UserHandle.of(mCurrentUser.getIdentifier())); - needToCheck = UserId.of(parentProfile); - } - - if (needToCheck != null && CrossProfileUtils.getCrossProfileResolveInfo(mCurrentUser, - mContext.getPackageManager(), intent, mContext, - mConfigStore.isPrivateSpaceInDocsUIEnabled()) != null) { - if (parentOrDelegatedFromParent.contains(needToCheck)) { - canForwardToProfileIds.addAll(parentOrDelegatedFromParent); - } else { - canForwardToProfileIds.add(needToCheck); + // CrossProfileContentSharingStrategyDelegatedFromParent is only V+ sdks. + if (SdkLevel.isAtLeastV() + && isCrossProfileContentSharingStrategyDelegatedFromParent( + UserHandle.of(userId.getIdentifier()))) { + delegatedFromParent.add(userId); + continue; } + + // Check for cross profile & add to the map. + profileIsAccessibleToProcessOwner.put( + userId, doesCrossProfileForwardingActivityExistForUser(intent, userId)); } - if (parentOrDelegatedFromParent.contains(mCurrentUser)) { - canForwardToProfileIds.addAll(parentOrDelegatedFromParent); + // For profiles that delegate their access to the parent, set the access for + // those profiles + // equal to the same as their parent. + for (UserId userId : delegatedFromParent) { + UserHandle parent = + mUserManager.getProfileParent(UserHandle.of(userId.getIdentifier())); + profileIsAccessibleToProcessOwner.put( + userId, + profileIsAccessibleToProcessOwner.getOrDefault( + UserId.of(parent), /* default= */ false)); } - for (UserId userId : userIds) { - synchronized (mCanFrowardToProfileIdMap) { - if (userId.equals(mCurrentUser)) { - mCanFrowardToProfileIdMap.put(userId, true); - continue; + synchronized (mCanForwardToProfileIdMap) { + mCanForwardToProfileIdMap.clear(); + for (Map.Entry<UserId, Boolean> entry : + profileIsAccessibleToProcessOwner.entrySet()) { + mCanForwardToProfileIdMap.put(entry.getKey(), entry.getValue()); + } + } + } + + /** + * Looks for a matching CrossProfileIntentForwardingActivity in the targetUserId for the + * given intent. + * + * @param intent The intent the forwarding activity needs to match. + * @param targetUserId The target user to check for. + * @return whether a CrossProfileIntentForwardingActivity could be found for the given + * intent, and user. + */ + private boolean doesCrossProfileForwardingActivityExistForUser( + Intent intent, UserId targetUserId) { + + final PackageManager pm = mContext.getPackageManager(); + final Intent intentToCheck = (Intent) intent.clone(); + intentToCheck.setComponent(null); + intentToCheck.setPackage(null); + + for (ResolveInfo resolveInfo : + pm.queryIntentActivities(intentToCheck, PackageManager.MATCH_DEFAULT_ONLY)) { + + if (resolveInfo.isCrossProfileIntentForwarderActivity()) { + /* + * IMPORTANT: This is a reflection based hack to ensure the profile is + * actually the installer of the CrossProfileIntentForwardingActivity. + * + * ResolveInfo.targetUserId exists, but is a hidden API not available to + * mainline modules, and no such API exists, so it is accessed via + * reflection below. All exceptions are caught to protect against + * reflection related issues such as: + * NoSuchFieldException / IllegalAccessException / SecurityException. + * + * In the event of an exception, the code fails "closed" for the current + * profile to avoid showing content that should not be visible. + */ + try { + Field targetUserIdField = + resolveInfo.getClass().getDeclaredField("targetUserId"); + targetUserIdField.setAccessible(true); + int activityTargetUserId = (int) targetUserIdField.get(resolveInfo); + + if (activityTargetUserId == targetUserId.getIdentifier()) { + + // Found a match for this profile + return true; + } + + } catch (NoSuchFieldException | IllegalAccessException | SecurityException ex) { + // Couldn't check the targetUserId via reflection, so fail without + // further iterations. + Log.e(TAG, "Could not access targetUserId via reflection.", ex); + return false; + } catch (Exception ex) { + Log.e(TAG, "Exception occurred during cross profile checks", ex); } - mCanFrowardToProfileIdMap.put(userId, canForwardToProfileIds.contains(userId)); } } + + // No match found, so return false. + return false; } @SuppressLint("NewApi") @@ -636,30 +775,12 @@ public interface UserManagerState { == UserProperties.CROSS_PROFILE_CONTENT_SHARING_DELEGATE_FROM_PARENT; } - private void getCanForwardToProfileIdMapPreV(Intent intent) { - // There only two profiles pre V - List<UserId> userIds = getUserIds(); - for (UserId userId : userIds) { - synchronized (mCanFrowardToProfileIdMap) { - if (mCurrentUser.equals(userId)) { - mCanFrowardToProfileIdMap.put(userId, true); - } else { - mCanFrowardToProfileIdMap.put(userId, - CrossProfileUtils.getCrossProfileResolveInfo( - mCurrentUser, mContext.getPackageManager(), intent, - mContext, mConfigStore.isPrivateSpaceInDocsUIEnabled()) - != null); - } - } - } - } - private static boolean isDeviceSupported(Context context) { - // The feature requires Android R DocumentsContract APIs and INTERACT_ACROSS_USERS_FULL - // permission. + // The feature requires Android R DocumentsContract APIs and + // INTERACT_ACROSS_USERS_FULL permission. return VersionUtils.isAtLeastR() && context.checkSelfPermission(Manifest.permission.INTERACT_ACROSS_USERS) - == PackageManager.PERMISSION_GRANTED; + == PackageManager.PERMISSION_GRANTED; } } } diff --git a/src/com/android/documentsui/archives/Archive.java b/src/com/android/documentsui/archives/Archive.java index 7c0f47147..9889631ea 100644 --- a/src/com/android/documentsui/archives/Archive.java +++ b/src/com/android/documentsui/archives/Archive.java @@ -16,6 +16,8 @@ package com.android.documentsui.archives; +import static com.android.documentsui.base.SharedMinimal.DEBUG; + import android.content.Context; import android.content.res.AssetFileDescriptor; import android.database.Cursor; @@ -29,23 +31,23 @@ import android.system.ErrnoException; import android.system.Os; import android.system.OsConstants; import android.text.TextUtils; +import android.util.Log; import android.webkit.MimeTypeMap; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; -import androidx.core.util.Preconditions; + +import org.apache.commons.compress.archivers.ArchiveEntry; import java.io.Closeable; import java.io.File; import java.io.FileNotFoundException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import org.apache.commons.compress.archivers.ArchiveEntry; -import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; - /** * Provides basic implementation for creating, extracting and accessing * files within archives exposed by a document provider. @@ -55,7 +57,7 @@ import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; public abstract class Archive implements Closeable { private static final String TAG = "Archive"; - public static final String[] DEFAULT_PROJECTION = new String[] { + public static final String[] DEFAULT_PROJECTION = new String[]{ Document.COLUMN_DOCUMENT_ID, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_MIME_TYPE, @@ -90,28 +92,85 @@ public abstract class Archive implements Closeable { mEntries = new HashMap<>(); } - /** - * Returns a valid, normalized path for an entry. - */ + /** Returns a valid, normalized path for an entry. */ public static String getEntryPath(ArchiveEntry entry) { - if (entry instanceof ZipArchiveEntry) { - /** - * Some of archive entry doesn't have the same naming rule. - * For example: The name of 7 zip directory entry doesn't end with '/'. - * Only check for Zip archive. - */ - Preconditions.checkArgument(entry.isDirectory() == entry.getName().endsWith("/"), - "Ill-formated ZIP-file."); + final List<String> parts = new ArrayList<String>(); + boolean isDir = true; + + // Get the path that will be decomposed and normalized + final String in = entry.getName(); + + decompose: + for (int i = 0; i < in.length(); ) { + // Skip separators + if (in.charAt(i) == '/') { + isDir = true; + do { + if (++i == in.length()) break decompose; + } while (in.charAt(i) == '/'); + } + + // Found the beginning of a part + final int b = i; + assert (b < in.length()); + assert (in.charAt(b) != '/'); + + // Find the end of the part + do { + ++i; + } while (i < in.length() && in.charAt(i) != '/'); + + // Extract part + final String part = in.substring(b, i); + assert (!part.isEmpty()); + + // Special case if part is "." + if (part.equals(".")) { + isDir = true; + continue; + } + + // Special case if part is ".." + if (part.equals("..")) { + isDir = true; + if (!parts.isEmpty()) parts.remove(parts.size() - 1); + continue; + } + + // The part is either a directory or a file name + isDir = false; + parts.add(part); } - if (entry.getName().startsWith("/")) { - return entry.getName(); - } else { - return "/" + entry.getName(); + + // If the decomposed path looks like a directory but the archive entry says that it is not + // a directory entry, append "?" for the file name + if (isDir && !entry.isDirectory()) { + isDir = false; + parts.add("?"); } + + if (parts.isEmpty()) return "/"; + + // Construct the normalized path + final StringBuilder sb = new StringBuilder(in.length() + 3); + + for (final String part : parts) { + sb.append('/'); + sb.append(part); + } + + if (entry.isDirectory()) { + sb.append('/'); + } + + final String out = sb.toString(); + if (DEBUG) Log.d(TAG, "getEntryPath(" + in + ") -> " + out); + return out; } /** * Returns true if the file descriptor is seekable. + * * @param descriptor File descriptor to check. */ public static boolean canSeek(ParcelFileDescriptor descriptor) { diff --git a/src/com/android/documentsui/archives/ReadableArchive.java b/src/com/android/documentsui/archives/ReadableArchive.java index 302f582f5..ad9c8242e 100644 --- a/src/com/android/documentsui/archives/ReadableArchive.java +++ b/src/com/android/documentsui/archives/ReadableArchive.java @@ -105,10 +105,10 @@ public class ReadableArchive extends Archive { continue; } entryPath = getEntryPath(entry); - if (mEntries.containsKey(entryPath)) { - throw new IOException("Multiple entries with the same name are not supported."); + if (mEntries.putIfAbsent(entryPath, entry) != null) { + if (DEBUG) Log.d(TAG, "Ignored conflicting entry for '" + entryPath + "'"); + continue; } - mEntries.put(entryPath, entry); if (entry.isDirectory()) { mTree.put(entryPath, new ArrayList<ArchiveEntry>()); } diff --git a/src/com/android/documentsui/base/Menus.java b/src/com/android/documentsui/base/Menus.java index eba240c83..6ceea3269 100644 --- a/src/com/android/documentsui/base/Menus.java +++ b/src/com/android/documentsui/base/Menus.java @@ -19,6 +19,8 @@ package com.android.documentsui.base; import android.view.Menu; import android.view.MenuItem; +import androidx.annotation.NonNull; + public final class Menus { private Menus() {} @@ -41,7 +43,7 @@ public final class Menus { } /** Set enabled/disabled state of a menuItem, and updates its visibility. */ - public static void setEnabledAndVisible(MenuItem item, boolean enabled) { + public static void setEnabledAndVisible(@NonNull MenuItem item, boolean enabled) { item.setEnabled(enabled); item.setVisible(enabled); } diff --git a/src/com/android/documentsui/dirlist/AppsRowManager.java b/src/com/android/documentsui/dirlist/AppsRowManager.java index 297d1b2e3..eb0d8bf2e 100644 --- a/src/com/android/documentsui/dirlist/AppsRowManager.java +++ b/src/com/android/documentsui/dirlist/AppsRowManager.java @@ -16,6 +16,8 @@ package com.android.documentsui.dirlist; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; @@ -45,6 +47,7 @@ import java.util.Map; /** * A manager class stored apps row chip data list. Data will be synced by RootsFragment. + * TODO(b/379776735): Remove this after use_material3 flag is launched. */ public class AppsRowManager { @@ -102,6 +105,10 @@ public class AppsRowManager { } private boolean shouldShow(State state, boolean isSearchExpanded) { + if (isUseMaterial3FlagEnabled()) { + return false; + } + boolean isHiddenAction = state.action == State.ACTION_CREATE || state.action == State.ACTION_OPEN_TREE || state.action == State.ACTION_PICK_COPY_DESTINATION; diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java index e099ca734..855a8273d 100644 --- a/src/com/android/documentsui/dirlist/DirectoryFragment.java +++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java @@ -21,6 +21,8 @@ import static com.android.documentsui.base.SharedMinimal.DEBUG; import static com.android.documentsui.base.SharedMinimal.VERBOSE; import static com.android.documentsui.base.State.MODE_GRID; import static com.android.documentsui.base.State.MODE_LIST; +import static com.android.documentsui.util.FlagUtils.isDesktopFileHandlingFlagEnabled; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.app.ActivityManager; import android.content.BroadcastReceiver; @@ -609,11 +611,16 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On new RefreshHelper(mRefreshLayout::setEnabled) .attach(mRecView); - mActionModeController = mInjector.getActionModeController( - mSelectionMetadata, - this::handleMenuItemClick); - - mSelectionMgr.addObserver(mActionModeController); + if (isUseMaterial3FlagEnabled()) { + mSelectionMgr.addObserver(mActivity.getNavigator()); + mActivity.getNavigator().updateSelection(mSelectionMetadata, this::handleMenuItemClick); + } else { + mActionModeController = + mInjector.getActionModeController( + mSelectionMetadata, this::handleMenuItemClick); + assert (mActionModeController != null); + mSelectionMgr.addObserver(mActionModeController); + } mProfileTabsController = mInjector.profileTabsController; mSelectionMgr.addObserver(mProfileTabsController); @@ -916,6 +923,14 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On } } + private void closeSelectionBar() { + if (isUseMaterial3FlagEnabled()) { + mActivity.getNavigator().closeSelectionBar(); + } else { + mActionModeController.finishActionMode(); + } + } + private boolean handleMenuItemClick(MenuItem item) { if (mInjector.pickResult != null) { mInjector.pickResult.increaseActionCount(); @@ -924,9 +939,19 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On mSelectionMgr.copySelection(selection); final int id = item.getItemId(); - if (id == R.id.action_menu_select || id == R.id.dir_menu_open) { + if (isDesktopFileHandlingFlagEnabled() && id == R.id.dir_menu_open) { + // On desktop, "open" is displayed in file management mode (i.e. `files.MenuManager`). + // This menu item behaves the same as double click on the menu item which is handled by + // onItemActivated but since onItemActivated requires a RecylcerView ItemDetails, we're + // using viewDocument that takes a Selection. + viewDocument(selection); + return true; + } else if (id == R.id.action_menu_select || id == R.id.dir_menu_open) { + // Note: this code path is never executed for `dir_menu_open`. The menu item is always + // hidden unless the desktopFileHandling flag is enabled, in which case the menu item + // will be handled by the condition above. openDocuments(selection); - mActionModeController.finishActionMode(); + closeSelectionBar(); return true; } else if (id == R.id.action_menu_open_with || id == R.id.dir_menu_open_with) { showChooserForDoc(selection); @@ -946,22 +971,22 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On transferDocuments(selection, null, FileOperationService.OPERATION_COPY); // TODO: Only finish selection mode if copy-to is not canceled. // Need to plum down into handling the way we do with deleteDocuments. - mActionModeController.finishActionMode(); + closeSelectionBar(); return true; } else if (id == R.id.action_menu_compress || id == R.id.dir_menu_compress) { transferDocuments(selection, mState.stack, FileOperationService.OPERATION_COMPRESS); // TODO: Only finish selection mode if compress is not canceled. // Need to plum down into handling the way we do with deleteDocuments. - mActionModeController.finishActionMode(); + closeSelectionBar(); return true; // TODO: Implement extract (to the current directory). - } else if (id == R.id.action_menu_extract_to) { + } else if (id == R.id.action_menu_extract_to || id == R.id.option_menu_extract_all) { transferDocuments(selection, null, FileOperationService.OPERATION_EXTRACT); // TODO: Only finish selection mode if compress-to is not canceled. // Need to plum down into handling the way we do with deleteDocuments. - mActionModeController.finishActionMode(); + closeSelectionBar(); return true; } else if (id == R.id.action_menu_move_to) { if (mModel.hasDocuments(selection, DocumentFilters.NOT_MOVABLE)) { @@ -969,17 +994,17 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On return true; } // Exit selection mode first, so we avoid deselecting deleted documents. - mActionModeController.finishActionMode(); + closeSelectionBar(); transferDocuments(selection, null, FileOperationService.OPERATION_MOVE); return true; } else if (id == R.id.action_menu_inspect || id == R.id.dir_menu_inspect) { - mActionModeController.finishActionMode(); + closeSelectionBar(); assert selection.size() <= 1; DocumentInfo doc = selection.isEmpty() ? mActivity.getCurrentDirectory() : mModel.getDocuments(selection).get(0); - mActions.showInspector(doc); + mActions.showPreview(doc); return true; } else if (id == R.id.dir_menu_cut_to_clipboard) { mActions.cutToClipboard(); @@ -1012,9 +1037,11 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On mActions.showSortDialog(); return true; } + if (DEBUG) { - Log.d(TAG, "Unhandled menu item selected: " + item); + Log.d(TAG, "Cannot handle unexpected menu item " + id); } + return false; } @@ -1080,6 +1107,20 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On mActions.showChooserForDoc(doc); } + private void viewDocument(final Selection<String> selected) { + Metrics.logUserAction(MetricConsts.USER_ACTION_OPEN); + + if (selected.isEmpty()) { + return; + } + + assert selected.size() == 1; + DocumentInfo doc = + DocumentInfo.fromDirectoryCursor(mModel.getItem(selected.iterator().next())); + + mActions.openDocumentViewOnly(doc); + } + private void transferDocuments( final Selection<String> selected, @Nullable DocumentStack destination, final @OpType int mode) { @@ -1171,7 +1212,7 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mode); // This just identifies the type of request...we'll check it - // when we reveive a response. + // when we receive a response. startActivityForResult(intent, REQUEST_COPY_DESTINATION); } @@ -1494,7 +1535,11 @@ public class DirectoryFragment extends Fragment implements SwipeRefreshLayout.On // For orientation changed case, sometimes the docs loading comes after the menu // update. We need to update the menu here to ensure the status is correct. mInjector.menuManager.updateModel(mModel); - mInjector.menuManager.updateOptionMenu(); + if (isUseMaterial3FlagEnabled()) { + mActivity.getNavigator().updateActionMenu(); + } else { + mInjector.menuManager.updateOptionMenu(); + } if (VersionUtils.isAtLeastS()) { mActivity.updateHeader(update.hasCrossProfileException()); } else { diff --git a/src/com/android/documentsui/dirlist/DocumentHolder.java b/src/com/android/documentsui/dirlist/DocumentHolder.java index 26f527896..8e5f50636 100644 --- a/src/com/android/documentsui/dirlist/DocumentHolder.java +++ b/src/com/android/documentsui/dirlist/DocumentHolder.java @@ -18,6 +18,7 @@ package com.android.documentsui.dirlist; import static com.android.documentsui.DevicePolicyResources.Strings.PREVIEW_WORK_FILE_ACCESSIBILITY; import static com.android.documentsui.DevicePolicyResources.Strings.UNDEFINED; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -55,7 +56,9 @@ import javax.annotation.Nullable; public abstract class DocumentHolder extends RecyclerView.ViewHolder implements View.OnKeyListener { - static final float DISABLED_ALPHA = 0.3f; + static final float DISABLED_ALPHA = isUseMaterial3FlagEnabled() ? 0.6f : 0.3f; + + static final int THUMBNAIL_STROKE_WIDTH = isUseMaterial3FlagEnabled() ? 2 : 0; protected final Context mContext; diff --git a/src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java b/src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java index 4b66b857f..838b1fa72 100644 --- a/src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java +++ b/src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java @@ -16,10 +16,13 @@ package com.android.documentsui.dirlist; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + import android.content.Context; import android.content.res.TypedArray; import android.util.AttributeSet; import android.util.Log; +import android.util.TypedValue; import android.view.MotionEvent; import androidx.annotation.ColorRes; @@ -42,20 +45,37 @@ public class DocumentsSwipeRefreshLayout extends SwipeRefreshLayout { public DocumentsSwipeRefreshLayout(Context context, AttributeSet attrs) { super(context, attrs); - final int[] styledAttrs = {android.R.attr.colorAccent}; + if (isUseMaterial3FlagEnabled()) { + TypedValue spinnerColor = new TypedValue(); + context.getTheme() + .resolveAttribute( + com.google.android.material.R.attr.colorOnPrimaryContainer, + spinnerColor, + true); + setColorSchemeResources(spinnerColor.resourceId); + TypedValue spinnerBackgroundColor = new TypedValue(); + context.getTheme() + .resolveAttribute( + com.google.android.material.R.attr.colorPrimaryContainer, + spinnerBackgroundColor, + true); + setProgressBackgroundColorSchemeResource(spinnerBackgroundColor.resourceId); + } else { + final int[] styledAttrs = {android.R.attr.colorAccent}; - TypedArray a = context.obtainStyledAttributes(styledAttrs); - @ColorRes int colorId = a.getResourceId(0, -1); - if (colorId == -1) { - Log.w(TAG, "Retrieve colorAccent colorId from theme fail, assign R.color.primary"); - colorId = R.color.primary; + TypedArray a = context.obtainStyledAttributes(styledAttrs); + @ColorRes int colorId = a.getResourceId(0, -1); + if (colorId == -1) { + Log.w(TAG, "Retrieve colorAccent colorId from theme fail, assign R.color.primary"); + colorId = R.color.primary; + } + a.recycle(); + setColorSchemeResources(colorId); } - a.recycle(); - setColorSchemeResources(colorId); } @Override public boolean onInterceptTouchEvent(MotionEvent e) { return false; } -}
\ No newline at end of file +} diff --git a/src/com/android/documentsui/dirlist/GridDirectoryHolder.java b/src/com/android/documentsui/dirlist/GridDirectoryHolder.java index 7e9a32df2..b7a8b6d05 100644 --- a/src/com/android/documentsui/dirlist/GridDirectoryHolder.java +++ b/src/com/android/documentsui/dirlist/GridDirectoryHolder.java @@ -46,6 +46,7 @@ import com.android.modules.utils.build.SdkLevel; import java.util.Map; +// TODO(b/379776735): remove this file after use_material3 flag is launched. final class GridDirectoryHolder extends DocumentHolder { final TextView mTitle; diff --git a/src/com/android/documentsui/dirlist/GridDocumentHolder.java b/src/com/android/documentsui/dirlist/GridDocumentHolder.java index eb35b1a5f..f2802ff66 100644 --- a/src/com/android/documentsui/dirlist/GridDocumentHolder.java +++ b/src/com/android/documentsui/dirlist/GridDocumentHolder.java @@ -21,6 +21,7 @@ import static com.android.documentsui.DevicePolicyResources.Drawables.WORK_PROFI import static com.android.documentsui.base.DocumentInfo.getCursorInt; import static com.android.documentsui.base.DocumentInfo.getCursorLong; import static com.android.documentsui.base.DocumentInfo.getCursorString; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -35,6 +36,7 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import com.android.documentsui.ConfigStore; @@ -42,11 +44,14 @@ import com.android.documentsui.DocumentsApplication; import com.android.documentsui.R; import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.Shared; +import com.android.documentsui.base.State; import com.android.documentsui.base.UserId; import com.android.documentsui.roots.RootCursorWrapper; import com.android.documentsui.ui.Views; import com.android.modules.utils.build.SdkLevel; +import com.google.android.material.card.MaterialCardView; + import java.util.Map; import java.util.function.Function; @@ -55,30 +60,49 @@ final class GridDocumentHolder extends DocumentHolder { final TextView mTitle; final TextView mDate; final TextView mDetails; + // Non-null only when useMaterial3 flag is ON. + final @Nullable TextView mBullet; final ImageView mIconMimeLg; - final ImageView mIconMimeSm; + // Null when useMaterial3 flag is ON. + final @Nullable ImageView mIconMimeSm; final ImageView mIconThumb; - final ImageView mIconCheck; + // Null when useMaterial3 flag is ON. + final @Nullable ImageView mIconCheck; final ImageView mIconBadge; final IconHelper mIconHelper; - final View mIconLayout; + // Null when useMaterial3 flag is ON. + final @Nullable View mIconLayout; final View mPreviewIcon; // This is used in as a convenience in our bind method. private final DocumentInfo mDoc = new DocumentInfo(); + // Non-null only when useMaterial3 flag is ON. + private final @Nullable MaterialCardView mIconWrapper; + GridDocumentHolder(Context context, ViewGroup parent, IconHelper iconHelper, ConfigStore configStore) { super(context, parent, R.layout.item_doc_grid, configStore); - mIconLayout = itemView.findViewById(R.id.icon); + if (isUseMaterial3FlagEnabled()) { + mBullet = itemView.findViewById(R.id.bullet); + mIconWrapper = itemView.findViewById(R.id.icon_wrapper); + mIconLayout = null; + mIconMimeSm = null; + mIconCheck = null; + } else { + mBullet = null; + mIconWrapper = null; + mIconLayout = itemView.findViewById(R.id.icon); + mIconMimeSm = (ImageView) itemView.findViewById(R.id.icon_mime_sm); + mIconCheck = (ImageView) itemView.findViewById(R.id.icon_check); + } + mTitle = (TextView) itemView.findViewById(android.R.id.title); mDate = (TextView) itemView.findViewById(R.id.date); mDetails = (TextView) itemView.findViewById(R.id.details); mIconMimeLg = (ImageView) itemView.findViewById(R.id.icon_mime_lg); - mIconMimeSm = (ImageView) itemView.findViewById(R.id.icon_mime_sm); mIconThumb = (ImageView) itemView.findViewById(R.id.icon_thumb); - mIconCheck = (ImageView) itemView.findViewById(R.id.icon_check); mIconBadge = (ImageView) itemView.findViewById(R.id.icon_profile_badge); mPreviewIcon = itemView.findViewById(R.id.preview_icon); @@ -99,17 +123,20 @@ final class GridDocumentHolder extends DocumentHolder { @Override public void setSelected(boolean selected, boolean animate) { - // We always want to make sure our check box disappears if we're not selected, - // even if the item is disabled. This is because this object can be reused - // and this method will be called to setup initial state. float checkAlpha = selected ? 1f : 0f; - if (animate) { - fade(mIconMimeSm, checkAlpha).start(); - fade(mIconCheck, checkAlpha).start(); - } else { - mIconCheck.setAlpha(checkAlpha); + if (!isUseMaterial3FlagEnabled()) { + // We always want to make sure our check box disappears if we're not selected, + // even if the item is disabled. This is because this object can be reused + // and this method will be called to setup initial state. + if (animate) { + fade(mIconMimeSm, checkAlpha).start(); + fade(mIconCheck, checkAlpha).start(); + } else { + mIconCheck.setAlpha(checkAlpha); + } } + // But it should be an error to be set to selected && be disabled. if (!itemView.isEnabled()) { assert (!selected); @@ -117,10 +144,21 @@ final class GridDocumentHolder extends DocumentHolder { super.setSelected(selected, animate); - if (animate) { - fade(mIconMimeSm, 1f - checkAlpha).start(); - } else { - mIconMimeSm.setAlpha(1f - checkAlpha); + if (!isUseMaterial3FlagEnabled()) { + if (animate) { + fade(mIconMimeSm, 1f - checkAlpha).start(); + } else { + mIconMimeSm.setAlpha(1f - checkAlpha); + } + } + + // Do not show stroke when selected, only show stroke when not selected if it has thumbnail. + if (mIconWrapper != null) { + if (selected) { + mIconWrapper.setStrokeWidth(0); + } else if (mIconThumb.getDrawable() != null) { + mIconWrapper.setStrokeWidth(THUMBNAIL_STROKE_WIDTH); + } } } @@ -131,19 +169,26 @@ final class GridDocumentHolder extends DocumentHolder { float imgAlpha = enabled ? 1f : DISABLED_ALPHA; mIconMimeLg.setAlpha(imgAlpha); - mIconMimeSm.setAlpha(imgAlpha); + if (!isUseMaterial3FlagEnabled()) { + mIconMimeSm.setAlpha(imgAlpha); + } mIconThumb.setAlpha(imgAlpha); } @Override public void bindPreviewIcon(boolean show, Function<View, Boolean> clickCallback) { + if (isUseMaterial3FlagEnabled() && mDoc.isDirectory()) { + mPreviewIcon.setVisibility(View.GONE); + return; + } mPreviewIcon.setVisibility(show ? View.VISIBLE : View.GONE); if (show) { mPreviewIcon.setContentDescription( getPreviewIconContentDescription( mIconHelper.shouldShowBadge(mDoc.userId.getIdentifier()), mDoc.displayName, mDoc.userId)); - mPreviewIcon.setAccessibilityDelegate(new PreviewAccessibilityDelegate(clickCallback)); + mPreviewIcon.setAccessibilityDelegate( + new PreviewAccessibilityDelegate(clickCallback)); } } @@ -171,6 +216,10 @@ final class GridDocumentHolder extends DocumentHolder { @Override public boolean inSelectRegion(MotionEvent event) { + if (isUseMaterial3FlagEnabled()) { + return (mDoc.isDirectory() && !(mAction == State.ACTION_BROWSE)) ? false + : Views.isEventOver(event, itemView.getParent(), mIconWrapper); + } return Views.isEventOver(event, itemView.getParent(), mIconLayout); } @@ -202,7 +251,21 @@ final class GridDocumentHolder extends DocumentHolder { mIconThumb.animate().cancel(); mIconThumb.setAlpha(0f); - mIconHelper.load(mDoc, mIconThumb, mIconMimeLg, mIconMimeSm); + if (isUseMaterial3FlagEnabled()) { + mIconHelper.load( + mDoc, mIconThumb, mIconMimeLg, /* subIconMime= */ null, + thumbnailLoaded -> { + // Show stroke when thumbnail is loaded. + if (mIconWrapper != null) { + mIconWrapper.setStrokeWidth( + thumbnailLoaded ? THUMBNAIL_STROKE_WIDTH : 0); + } + }); + } else { + mIconHelper.load( + mDoc, mIconThumb, mIconMimeLg, mIconMimeSm, /* thumbnailLoadedCallback= */ + null); + } mTitle.setText(mDoc.displayName, TextView.BufferType.SPANNABLE); mTitle.setVisibility(View.VISIBLE); @@ -229,5 +292,11 @@ final class GridDocumentHolder extends DocumentHolder { mDetails.setText(Formatter.formatFileSize(mContext, docSize)); } } + + if (mBullet != null && (mDetails.getVisibility() == View.GONE + || mDate.getText().isEmpty())) { + // There is no need for the bullet separating the details and date. + mBullet.setVisibility(View.GONE); + } } } diff --git a/src/com/android/documentsui/dirlist/GridPhotoHolder.java b/src/com/android/documentsui/dirlist/GridPhotoHolder.java index e86d3131f..70c8a6ffc 100644 --- a/src/com/android/documentsui/dirlist/GridPhotoHolder.java +++ b/src/com/android/documentsui/dirlist/GridPhotoHolder.java @@ -189,7 +189,12 @@ final class GridPhotoHolder extends DocumentHolder { mIconThumb.animate().cancel(); mIconThumb.setAlpha(0f); - mIconHelper.load(mDoc, mIconThumb, mIconMimeLg, null); + mIconHelper.load( + mDoc, + mIconThumb, + mIconMimeLg, + /* subIconMime= */ null, + /* thumbnailLoadedCallback= */ null); final String docSize = Formatter.formatFileSize(mContext, getCursorLong(cursor, Document.COLUMN_SIZE)); diff --git a/src/com/android/documentsui/dirlist/IconHelper.java b/src/com/android/documentsui/dirlist/IconHelper.java index 44d1d95d3..6d53bbee1 100644 --- a/src/com/android/documentsui/dirlist/IconHelper.java +++ b/src/com/android/documentsui/dirlist/IconHelper.java @@ -52,6 +52,7 @@ import com.android.documentsui.base.UserId; import com.android.modules.utils.build.SdkLevel; import java.util.function.BiConsumer; +import java.util.function.Consumer; /** * A class to assist with loading and managing the Images (i.e. thumbnails and icons) associated @@ -151,14 +152,18 @@ public class IconHelper { * @param iconThumb The itemview's thumbnail icon. * @param iconMime The itemview's mime icon. Hidden when iconThumb is shown. * @param subIconMime The second itemview's mime icon. Always visible. + * @param thumbnailLoadedCallback The callback function which will be invoked after the + * thumbnail is loaded, with a boolean parameter to indicate + * if it's loaded or not. */ public void load( DocumentInfo doc, ImageView iconThumb, ImageView iconMime, - @Nullable ImageView subIconMime) { + @Nullable ImageView subIconMime, + @Nullable Consumer<Boolean> thumbnailLoadedCallback) { load(doc.derivedUri, doc.userId, doc.mimeType, doc.flags, doc.icon, doc.lastModified, - iconThumb, iconMime, subIconMime); + iconThumb, iconMime, subIconMime, thumbnailLoadedCallback); } /** @@ -172,10 +177,13 @@ public class IconHelper { * @param iconThumb The itemview's thumbnail icon. * @param iconMime The itemview's mime icon. Hidden when iconThumb is shown. * @param subIconMime The second itemview's mime icon. Always visible. + * @param thumbnailLoadedCallback The callback function which will be invoked after the + * thumbnail is loaded, with a boolean parameter to indicate + * if it's loaded or not. */ public void load(Uri uri, UserId userId, String mimeType, int docFlags, int docIcon, long docLastModified, ImageView iconThumb, ImageView iconMime, - @Nullable ImageView subIconMime) { + @Nullable ImageView subIconMime, @Nullable Consumer<Boolean> thumbnailLoadedCallback) { boolean loadedThumbnail = false; final String docAuthority = uri.getAuthority(); @@ -186,7 +194,14 @@ public class IconHelper { final boolean showThumbnail = supportsThumbnail && allowThumbnail && mThumbnailsEnabled; if (showThumbnail) { loadedThumbnail = - loadThumbnail(uri, userId, docAuthority, docLastModified, iconThumb, iconMime); + loadThumbnail( + uri, + userId, + docAuthority, + docLastModified, + iconThumb, + iconMime, + thumbnailLoadedCallback); } final Drawable mimeIcon = getDocumentIcon(mContext, userId, docAuthority, @@ -202,15 +217,22 @@ public class IconHelper { setMimeIcon(iconMime, mimeIcon); hideImageView(iconThumb); } + if (thumbnailLoadedCallback != null) { + thumbnailLoadedCallback.accept(loadedThumbnail); + } } private boolean loadThumbnail(Uri uri, UserId userId, String docAuthority, long docLastModified, - ImageView iconThumb, ImageView iconMime) { + ImageView iconThumb, ImageView iconMime, + @Nullable Consumer<Boolean> thumbnailLoadedCallback) { final Result result = mThumbnailCache.getThumbnail(uri, userId, mCurrentSize); try { final Bitmap cachedThumbnail = result.getThumbnail(); iconThumb.setImageBitmap(cachedThumbnail); + if (thumbnailLoadedCallback != null) { + thumbnailLoadedCallback.accept(cachedThumbnail != null); + } boolean stale = (docLastModified > result.getLastModified()); if (VERBOSE) { @@ -230,6 +252,9 @@ public class IconHelper { iconThumb.setImageBitmap(bitmap); animator.accept(iconMime, iconThumb); } + if (thumbnailLoadedCallback != null) { + thumbnailLoadedCallback.accept(bitmap != null); + } }, true /* addToCache */); ProviderExecutor.forAuthority(docAuthority).execute(task); diff --git a/src/com/android/documentsui/dirlist/ListDocumentHolder.java b/src/com/android/documentsui/dirlist/ListDocumentHolder.java index 2c09d3aec..0d0f79919 100644 --- a/src/com/android/documentsui/dirlist/ListDocumentHolder.java +++ b/src/com/android/documentsui/dirlist/ListDocumentHolder.java @@ -20,6 +20,7 @@ import static com.android.documentsui.DevicePolicyResources.Drawables.Style.SOLI import static com.android.documentsui.DevicePolicyResources.Drawables.WORK_PROFILE_ICON; import static com.android.documentsui.base.DocumentInfo.getCursorInt; import static com.android.documentsui.base.DocumentInfo.getCursorString; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -52,6 +53,8 @@ import com.android.documentsui.roots.RootCursorWrapper; import com.android.documentsui.ui.Views; import com.android.modules.utils.build.SdkLevel; +import com.google.android.material.card.MaterialCardView; + import java.util.ArrayList; import java.util.Map; import java.util.function.Function; @@ -67,6 +70,8 @@ final class ListDocumentHolder extends DocumentHolder { private final @Nullable LinearLayout mDetails; // TextView for date + size + summary, null only for tablets/sw720dp private final @Nullable TextView mMetadataView; + // Non-null only when use_material3 flag is ON. + private final @Nullable MaterialCardView mIconWrapper; private final ImageView mIconMime; private final ImageView mIconThumb; private final ImageView mIconCheck; @@ -84,6 +89,8 @@ final class ListDocumentHolder extends DocumentHolder { super(context, parent, R.layout.item_doc_list, configStore); mIconLayout = itemView.findViewById(R.id.icon); + mIconWrapper = + isUseMaterial3FlagEnabled() ? itemView.findViewById(R.id.icon_wrapper) : null; mIconMime = (ImageView) itemView.findViewById(R.id.icon_mime); mIconThumb = (ImageView) itemView.findViewById(R.id.icon_thumb); mIconCheck = (ImageView) itemView.findViewById(R.id.icon_check); @@ -139,16 +146,29 @@ final class ListDocumentHolder extends DocumentHolder { mIconMime.setAlpha(1f - checkAlpha); mIconThumb.setAlpha(1f - checkAlpha); } + + // Do not show stroke when selected, only show stroke when not selected if it has thumbnail. + if (isUseMaterial3FlagEnabled() && mIconWrapper != null) { + if (selected) { + mIconWrapper.setStrokeWidth(0); + } else if (mIconThumb.getDrawable() != null) { + mIconWrapper.setStrokeWidth(2); + } + } } @Override public void setEnabled(boolean enabled) { super.setEnabled(enabled); - // Text colors enabled/disabled is handle via a color set. - final float imgAlpha = enabled ? 1f : DISABLED_ALPHA; - mIconMime.setAlpha(imgAlpha); - mIconThumb.setAlpha(imgAlpha); + if (isUseMaterial3FlagEnabled()) { + itemView.setAlpha(enabled ? 1f : DISABLED_ALPHA); + } else { + // Text colors enabled/disabled is handle via a color set. + final float imgAlpha = enabled ? 1f : DISABLED_ALPHA; + mIconMime.setAlpha(imgAlpha); + mIconThumb.setAlpha(imgAlpha); + } } @Override @@ -243,7 +263,17 @@ final class ListDocumentHolder extends DocumentHolder { mIconThumb.animate().cancel(); mIconThumb.setAlpha(0f); - mIconHelper.load(mDoc, mIconThumb, mIconMime, null); + mIconHelper.load( + mDoc, + mIconThumb, + mIconMime, + /* subIconMime= */ null, + thumbnailLoaded -> { + // Show stroke when thumbnail is loaded. + if (isUseMaterial3FlagEnabled() && mIconWrapper != null) { + mIconWrapper.setStrokeWidth(thumbnailLoaded ? THUMBNAIL_STROKE_WIDTH : 0); + } + }); mTitle.setText(mDoc.displayName, TextView.BufferType.SPANNABLE); mTitle.setVisibility(View.VISIBLE); diff --git a/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java b/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java index fc60d07a3..112b70da8 100644 --- a/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java +++ b/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java @@ -20,6 +20,7 @@ import static com.android.documentsui.base.DocumentInfo.getCursorInt; import static com.android.documentsui.base.DocumentInfo.getCursorString; import static com.android.documentsui.base.State.MODE_GRID; import static com.android.documentsui.base.State.MODE_LIST; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.database.Cursor; import android.provider.DocumentsContract.Document; @@ -95,8 +96,12 @@ final class ModelBackedDocumentsAdapter extends DocumentsAdapter { case MODE_GRID: switch (viewType) { case ITEM_TYPE_DIRECTORY: - holder = - new GridDirectoryHolder( + // Under the Material3 flag, the GridDocumentHolder is the holder for all + // grid items. + holder = isUseMaterial3FlagEnabled() + ? new GridDocumentHolder( + mEnv.getContext(), parent, mIconHelper, mConfigStore) + : new GridDirectoryHolder( mEnv.getContext(), parent, mIconHelper, mConfigStore); break; case ITEM_TYPE_DOCUMENT: diff --git a/src/com/android/documentsui/dirlist/SelectionMetadata.java b/src/com/android/documentsui/dirlist/SelectionMetadata.java index 3abc3e190..74b6061b3 100644 --- a/src/com/android/documentsui/dirlist/SelectionMetadata.java +++ b/src/com/android/documentsui/dirlist/SelectionMetadata.java @@ -168,7 +168,7 @@ public class SelectionMetadata extends SelectionObserver<String> } @Override - public boolean canOpenWith() { + public boolean canOpen() { return size() == 1 && mDirectoryCount == 0 && mInArchiveCount == 0 && mPartialCount == 0; } } diff --git a/src/com/android/documentsui/files/ActionHandler.java b/src/com/android/documentsui/files/ActionHandler.java index 20b831856..86f7a1a14 100644 --- a/src/com/android/documentsui/files/ActionHandler.java +++ b/src/com/android/documentsui/files/ActionHandler.java @@ -19,10 +19,14 @@ package com.android.documentsui.files; import static android.content.ContentResolver.wrap; import static com.android.documentsui.base.SharedMinimal.DEBUG; +import static com.android.documentsui.util.FlagUtils.isDesktopFileHandlingFlagEnabled; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; +import static com.android.documentsui.util.FlagUtils.isUsePeekPreviewFlagEnabled; import android.app.DownloadManager; import android.content.ActivityNotFoundException; import android.content.ClipData; +import android.content.ComponentName; import android.content.ContentProviderClient; import android.content.ContentResolver; import android.content.Intent; @@ -96,6 +100,7 @@ public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.Co private final DocumentClipper mClipper; private final ClipStore mClipStore; private final DragAndDropManager mDragAndDropManager; + private final Runnable mCloseSelectionBar; ActionHandler( T activity, @@ -104,7 +109,8 @@ public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.Co DocumentsAccess docs, SearchViewManager searchMgr, Lookup<String, Executor> executors, - ActionModeAddons actionModeAddons, + @Nullable ActionModeAddons actionModeAddons, + Runnable closeSelectionBar, DocumentClipper clipper, ClipStore clipStore, DragAndDropManager dragAndDropManager, @@ -113,6 +119,7 @@ public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.Co super(activity, state, providers, docs, searchMgr, executors, injector); mActionModeAddons = actionModeAddons; + mCloseSelectionBar = closeSelectionBar; mFeatures = injector.features; mConfig = injector.config; mClipper = clipper; @@ -221,9 +228,19 @@ public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.Co } @Override + public void openDocumentViewOnly(DocumentInfo doc) { + mInjector.searchManager.recordHistory(); + openDocument(doc, VIEW_TYPE_REGULAR, VIEW_TYPE_NONE); + } + + @Override public void springOpenDirectory(DocumentInfo doc) { - assert(doc.isDirectory()); - mActionModeAddons.finishActionMode(); + assert (doc.isDirectory()); + if (isUseMaterial3FlagEnabled()) { + mCloseSelectionBar.run(); + } else { + mActionModeAddons.finishActionMode(); + } openContainerDocument(doc); } @@ -315,7 +332,11 @@ public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.Co return; } - mActionModeAddons.finishActionMode(); + if (isUseMaterial3FlagEnabled()) { + mCloseSelectionBar.run(); + } else { + mActionModeAddons.finishActionMode(); + } List<Uri> uris = new ArrayList<>(docs.size()); for (DocumentInfo doc : docs) { @@ -543,17 +564,27 @@ public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.Co return; } - Intent intent = Intent.createChooser(buildViewIntent(doc), null); - intent.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); - try { - doc.userId.startActivityAsUser(mActivity, intent); - } catch (ActivityNotFoundException e) { - mDialogs.showNoApplicationFound(); + if (isDesktopFileHandlingFlagEnabled()) { + Intent intent = buildViewIntent(doc); + intent.setComponent( + new ComponentName("android", "com.android.internal.app.ResolverActivity")); + try { + doc.userId.startActivityAsUser(mActivity, intent); + } catch (ActivityNotFoundException e) { + mDialogs.showNoApplicationFound(); + } + } else { + Intent intent = Intent.createChooser(buildViewIntent(doc), null); + intent.putExtra(Intent.EXTRA_AUTO_LAUNCH_SINGLE_CHOICE, false); + try { + doc.userId.startActivityAsUser(mActivity, intent); + } catch (ActivityNotFoundException e) { + mDialogs.showNoApplicationFound(); + } } } - @Override - public void showInspector(DocumentInfo doc) { + private void showInspector(DocumentInfo doc) { Metrics.logUserAction(MetricConsts.USER_ACTION_INSPECTOR); Intent intent = InspectorActivity.createIntent(mActivity, doc.derivedUri, doc.userId); @@ -577,4 +608,17 @@ public class ActionHandler<T extends FragmentActivity & AbstractActionHandler.Co } mActivity.startActivity(intent); } + + private void showPeek() { + Log.d(TAG, "Peek not implemented"); + } + + @Override + public void showPreview(DocumentInfo doc) { + if (isUseMaterial3FlagEnabled() && isUsePeekPreviewFlagEnabled()) { + showPeek(); + } else { + showInspector(doc); + } + } } diff --git a/src/com/android/documentsui/files/FilesActivity.java b/src/com/android/documentsui/files/FilesActivity.java index 1ebe2374f..50e266d38 100644 --- a/src/com/android/documentsui/files/FilesActivity.java +++ b/src/com/android/documentsui/files/FilesActivity.java @@ -17,12 +17,16 @@ package com.android.documentsui.files; import static com.android.documentsui.OperationDialogFragment.DIALOG_TYPE_UNKNOWN; +import static com.android.documentsui.base.SharedMinimal.DEBUG; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; +import static com.android.documentsui.util.FlagUtils.isZipNgFlagEnabled; import android.app.ActivityManager.TaskDescription; import android.content.Intent; import android.graphics.Color; import android.net.Uri; import android.os.Bundle; +import android.util.Log; import android.view.KeyEvent; import android.view.KeyboardShortcutGroup; import android.view.Menu; @@ -136,25 +140,30 @@ public class FilesActivity extends BaseActivity implements AbstractActionHandler mInjector.getModel()::getItemUri, mInjector.getModel()::getItemCount); - mInjector.actionModeController = new ActionModeController( - this, - mInjector.selectionMgr, - mNavigator, - mInjector.menuManager, - mInjector.messages); + if (!isUseMaterial3FlagEnabled()) { + mInjector.actionModeController = + new ActionModeController( + this, + mInjector.selectionMgr, + mNavigator, + mInjector.menuManager, + mInjector.messages); + } - mInjector.actions = new ActionHandler<>( - this, - mState, - mProviders, - mDocs, - mSearchManager, - ProviderExecutor::forAuthority, - mInjector.actionModeController, - clipper, - DocumentsApplication.getClipStore(this), - DocumentsApplication.getDragAndDropManager(this), - mInjector); + mInjector.actions = + new ActionHandler<>( + this, + mState, + mProviders, + mDocs, + mSearchManager, + ProviderExecutor::forAuthority, + mInjector.actionModeController, + getNavigator()::closeSelectionBar, + clipper, + DocumentsApplication.getClipStore(this), + DocumentsApplication.getDragAndDropManager(this), + mInjector); mInjector.searchManager = mSearchManager; @@ -181,6 +190,14 @@ public class FilesActivity extends BaseActivity implements AbstractActionHandler RootsFragment.show(getSupportFragmentManager(), /* includeApps= */ false, /* intent= */ null); + if (isUseMaterial3FlagEnabled()) { + View navRailRoots = findViewById(R.id.nav_rail_container_roots); + if (navRailRoots != null) { + // Medium layout, populate navigation rail layout. + RootsFragment.showNavRail(getSupportFragmentManager(), /* includeApps= */ false, + /* intent= */ null); + } + } final Intent intent = getIntent(); @@ -315,7 +332,9 @@ public class FilesActivity extends BaseActivity implements AbstractActionHandler @Override public boolean onPrepareOptionsMenu(Menu menu) { super.onPrepareOptionsMenu(menu); - mInjector.menuManager.updateOptionMenu(menu); + if (!isUseMaterial3FlagEnabled()) { + mInjector.menuManager.updateOptionMenu(menu); + } return true; } @@ -329,12 +348,22 @@ public class FilesActivity extends BaseActivity implements AbstractActionHandler mInjector.actions.openInNewWindow(mState.stack); } else if (id == R.id.option_menu_settings) { mInjector.actions.openSettings(getCurrentRoot()); + } else if (id == R.id.option_menu_extract_all) { + if (!isZipNgFlagEnabled()) return false; + final DirectoryFragment dir = getDirectoryFragment(); + if (dir == null) return false; + mInjector.actions.selectAllFiles(); + return dir.onContextItemSelected(item); } else if (id == R.id.option_menu_select_all) { mInjector.actions.selectAllFiles(); } else if (id == R.id.option_menu_inspect) { - mInjector.actions.showInspector(getCurrentDirectory()); + mInjector.actions.showPreview(getCurrentDirectory()); } else { - return super.onOptionsItemSelected(item); + final boolean ok = super.onOptionsItemSelected(item); + if (DEBUG && !ok) { + Log.d(TAG, "Unhandled option item " + id); + } + return ok; } return true; } diff --git a/src/com/android/documentsui/files/MenuManager.java b/src/com/android/documentsui/files/MenuManager.java index 742bc9739..9b3564eeb 100644 --- a/src/com/android/documentsui/files/MenuManager.java +++ b/src/com/android/documentsui/files/MenuManager.java @@ -16,6 +16,8 @@ package com.android.documentsui.files; +import static com.android.documentsui.util.FlagUtils.isDesktopFileHandlingFlagEnabled; + import android.content.Context; import android.content.res.Resources; import android.net.Uri; @@ -161,7 +163,13 @@ public final class MenuManager extends com.android.documentsui.MenuManager { @Override protected void updateOpenWith(MenuItem openWith, SelectionDetails selectionDetails) { - Menus.setEnabledAndVisible(openWith, selectionDetails.canOpenWith()); + Menus.setEnabledAndVisible(openWith, selectionDetails.canOpen()); + } + + @Override + protected void updateOpenInContextMenu(MenuItem open, SelectionDetails selectionDetails) { + Menus.setEnabledAndVisible( + open, isDesktopFileHandlingFlagEnabled() && selectionDetails.canOpen()); } @Override @@ -218,6 +226,11 @@ public final class MenuManager extends com.android.documentsui.MenuManager { } @Override + protected void updateExtractAll(MenuItem it) { + Menus.setEnabledAndVisible(it, mDirDetails.isInArchive()); + } + + @Override protected void updateSelectAll(MenuItem selectAll) { Menus.setEnabledAndVisible(selectAll, true); } diff --git a/src/com/android/documentsui/loaders/BaseFileLoader.kt b/src/com/android/documentsui/loaders/BaseFileLoader.kt new file mode 100644 index 000000000..dd76217ac --- /dev/null +++ b/src/com/android/documentsui/loaders/BaseFileLoader.kt @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.documentsui.loaders + +import android.content.Context +import android.database.Cursor +import android.database.MatrixCursor +import android.database.MergeCursor +import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal +import android.os.RemoteException +import android.provider.DocumentsContract.Document +import android.util.Log +import androidx.loader.content.AsyncTaskLoader +import com.android.documentsui.DirectoryResult +import com.android.documentsui.base.Lookup +import com.android.documentsui.base.UserId +import com.android.documentsui.roots.RootCursorWrapper + +const val TAG = "SearchV2" + +val FILE_ENTRY_COLUMNS = arrayOf( + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_FLAGS, + Document.COLUMN_SUMMARY, + Document.COLUMN_SIZE, + Document.COLUMN_ICON, +) + +fun emptyCursor(): Cursor { + return MatrixCursor(FILE_ENTRY_COLUMNS) +} + +/** + * Helper function that returns a single, non-null cursor constructed from the given list of + * cursors. + */ +fun toSingleCursor(cursorList: List<Cursor>): Cursor { + if (cursorList.isEmpty()) { + return emptyCursor() + } + if (cursorList.size == 1) { + return cursorList[0] + } + return MergeCursor(cursorList.toTypedArray()) +} + +/** + * The base class for search and directory loaders. This class implements common functionality + * shared by these loaders. The extending classes should implement loadInBackground, which + * should call the queryLocation method. + */ +abstract class BaseFileLoader( + context: Context, + private val mUserIdList: List<UserId>, + protected val mMimeTypeLookup: Lookup<String, String>, +) : AsyncTaskLoader<DirectoryResult>(context) { + + private var mSignal: CancellationSignal? = null + private var mResult: DirectoryResult? = null + + override fun cancelLoadInBackground() { + Log.d(TAG, "BasedFileLoader.cancelLoadInBackground") + super.cancelLoadInBackground() + + synchronized(this) { + mSignal?.cancel() + } + } + + override fun deliverResult(result: DirectoryResult?) { + Log.d(TAG, "BasedFileLoader.deliverResult") + if (isReset) { + closeResult(result) + return + } + val oldResult: DirectoryResult? = mResult + mResult = result + + if (isStarted) { + super.deliverResult(result) + } + + if (oldResult != null && oldResult !== result) { + closeResult(oldResult) + } + } + + override fun onStartLoading() { + Log.d(TAG, "BasedFileLoader.onStartLoading") + val isCursorStale: Boolean = checkIfCursorStale(mResult) + if (mResult != null && !isCursorStale) { + deliverResult(mResult) + } + if (takeContentChanged() || mResult == null || isCursorStale) { + forceLoad() + } + } + + override fun onStopLoading() { + Log.d(TAG, "BasedFileLoader.onStopLoading") + cancelLoad() + } + + override fun onCanceled(result: DirectoryResult?) { + Log.d(TAG, "BasedFileLoader.onCanceled") + closeResult(result) + } + + override fun onReset() { + Log.d(TAG, "BasedFileLoader.onReset") + super.onReset() + + // Ensure the loader is stopped + onStopLoading() + + closeResult(mResult) + mResult = null + } + + /** + * Quietly closes the result cursor, if results are still available. + */ + fun closeResult(result: DirectoryResult?) { + try { + result?.close() + } catch (e: Exception) { + Log.d(TAG, "Failed to close result", e) + } + } + + private fun checkIfCursorStale(result: DirectoryResult?): Boolean { + if (result == null) { + return true + } + val cursor = result.cursor ?: return true + if (cursor.isClosed) { + return true + } + Log.d(TAG, "Long check of cursor staleness") + val count = cursor.count + if (!cursor.moveToPosition(-1)) { + return true + } + for (i in 1..count) { + if (!cursor.moveToNext()) { + return true + } + } + return false + } + + /** + * A function that, for the specified location rooted in the root with the given rootId + * attempts to obtain a non-null cursor from the content provider client obtained for the + * given locationUri. It returns the first non-null cursor, if one can be found, or null, + * if it fails to query the given location for all known users. + */ + fun queryLocation( + rootId: String, + locationUri: Uri, + queryArgs: Bundle?, + maxResults: Int, + ): Cursor? { + val authority = locationUri.authority ?: return null + for (userId in mUserIdList) { + Log.d(TAG, "BaseFileLoader.queryLocation for $userId at $locationUri") + val resolver = userId.getContentResolver(context) + try { + resolver.acquireUnstableContentProviderClient( + authority + ).use { client -> + if (client == null) { + return null + } + try { + val cursor = + client.query(locationUri, null, queryArgs, mSignal) ?: return null + return RootCursorWrapper(userId, authority, rootId, cursor, maxResults) + } catch (e: RemoteException) { + Log.d(TAG, "Failed to get cursor for $locationUri", e) + } + } + } catch (e: Exception) { + Log.d(TAG, "Failed to get a content provider client for $locationUri", e) + } + } + + return null + } +} diff --git a/src/com/android/documentsui/loaders/FolderLoader.kt b/src/com/android/documentsui/loaders/FolderLoader.kt new file mode 100644 index 000000000..a166ca752 --- /dev/null +++ b/src/com/android/documentsui/loaders/FolderLoader.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.documentsui.loaders + +import android.content.Context +import android.provider.DocumentsContract +import com.android.documentsui.ContentLock +import com.android.documentsui.DirectoryResult +import com.android.documentsui.LockingContentObserver +import com.android.documentsui.base.DocumentInfo +import com.android.documentsui.base.FilteringCursorWrapper +import com.android.documentsui.base.Lookup +import com.android.documentsui.base.RootInfo +import com.android.documentsui.base.UserId +import com.android.documentsui.sorting.SortModel + +/** + * A specialization of the BaseFileLoader that loads the children of a single folder. To list + * a directory you need to provide: + * + * - The current application context + * - A content lock for which a locking content observer is built + * - A list of user IDs on behalf of which the search is conducted + * - The root info of the listed directory + * - The document info of the listed directory + * - a lookup from file extension to file type + * - The model capable of sorting results + */ +class FolderLoader( + context: Context, + userIdList: List<UserId>, + mimeTypeLookup: Lookup<String, String>, + contentLock: ContentLock, + private val mRoot: RootInfo, + private val mListedDir: DocumentInfo, + private val mOptions: QueryOptions, + private val mSortModel: SortModel, +) : BaseFileLoader(context, userIdList, mimeTypeLookup) { + + // An observer registered on the cursor to force a reload if the cursor reports a change. + private val mObserver = LockingContentObserver(contentLock, this::onContentChanged) + + // Creates a directory result object corresponding to the current parameters of the loader. + override fun loadInBackground(): DirectoryResult? { + val rejectBeforeTimestamp = mOptions.getRejectBeforeTimestamp() + val folderChildrenUri = DocumentsContract.buildChildDocumentsUri( + mListedDir.authority, + mListedDir.documentId + ) + var cursor = + queryLocation(mRoot.rootId, folderChildrenUri, mOptions.otherQueryArgs, ALL_RESULTS) + ?: emptyCursor() + cursor.registerContentObserver(mObserver) + + val filteredCursor = FilteringCursorWrapper(cursor) + filteredCursor.filterHiddenFiles(mOptions.showHidden) + filteredCursor.filterMimes(mOptions.acceptableMimeTypes, null) + if (rejectBeforeTimestamp > 0L) { + filteredCursor.filterLastModified(rejectBeforeTimestamp) + } + // TODO(b:380945065): Add filtering by category, such as images, audio, video. + val sortedCursor = mSortModel.sortCursor(filteredCursor, mMimeTypeLookup) + + val result = DirectoryResult() + result.doc = mListedDir + result.cursor = sortedCursor + return result + } +} diff --git a/src/com/android/documentsui/loaders/QueryOptions.kt b/src/com/android/documentsui/loaders/QueryOptions.kt new file mode 100644 index 000000000..385815e99 --- /dev/null +++ b/src/com/android/documentsui/loaders/QueryOptions.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.documentsui.loaders + +import android.os.Bundle +import java.time.Duration + +/** + * The constant to be used for the maxResults parameter, if we wish to get all (unlimited) results. + */ +const val ALL_RESULTS: Int = -1 + +/** + * Common query options. These are: + * - maximum number to return; pass ALL_RESULTS to impose no limits. + * - maximum lastModified delta in milliseconds: the delta from now used to reject files that were + * not modified in the specified milliseconds; pass null for no limits. + * - maximum time the query should return, including empty, results; pass null for no limits. + * - whether or not to show hidden files. + * - A list of MIME types used to filter returned files. + * - "Other" query arguments not covered by the above. + * + * The "other" query arguments are added as due to existing code communicating information such + * as acceptable file kind (images, videos, etc.) is done via Bundle arguments. This could be + * and should be changed if this code ever is rewritten. + * TODO(b:397095797): Merge otherQueryArgs with acceptableMimeTypes and maxLastModifiedDelta. + */ +data class QueryOptions( + val maxResults: Int, + val maxLastModifiedDelta: Duration?, + val maxQueryTime: Duration?, + val showHidden: Boolean, + val acceptableMimeTypes: Array<String>, + val otherQueryArgs: Bundle, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as QueryOptions + + return maxResults == other.maxResults && + maxLastModifiedDelta == other.maxLastModifiedDelta && + maxQueryTime == other.maxQueryTime && + showHidden == other.showHidden && + acceptableMimeTypes.contentEquals(other.acceptableMimeTypes) + } + + /** + * Helper method that computes the earliest valid last modified timestamp. Converts last + * modified duration to milliseconds past now. If the maxLastModifiedDelta is negative + * this method returns 0L. + */ + fun getRejectBeforeTimestamp() = + if (maxLastModifiedDelta == null) { + 0L + } else { + System.currentTimeMillis() - maxLastModifiedDelta.toMillis() + } + + /** + * Helper function that indicates if query time is unlimited. Due to internal reliance on + * Java's Duration class it assumes anything larger than 60 seconds has unlimited waiting + * time. + */ + fun isQueryTimeUnlimited() = maxQueryTime == null + + override fun hashCode(): Int { + var result = maxResults + result = 31 * result + maxLastModifiedDelta.hashCode() + result = 31 * result + maxQueryTime.hashCode() + result = 31 * result + showHidden.hashCode() + result = 31 * result + acceptableMimeTypes.contentHashCode() + return result + } +} diff --git a/src/com/android/documentsui/loaders/SearchLoader.kt b/src/com/android/documentsui/loaders/SearchLoader.kt new file mode 100644 index 000000000..f0da924e2 --- /dev/null +++ b/src/com/android/documentsui/loaders/SearchLoader.kt @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.documentsui.loaders + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import android.provider.DocumentsContract +import android.provider.DocumentsContract.Document +import android.text.TextUtils +import android.util.Log +import com.android.documentsui.DirectoryResult +import com.android.documentsui.LockingContentObserver +import com.android.documentsui.base.DocumentInfo +import com.android.documentsui.base.FilteringCursorWrapper +import com.android.documentsui.base.Lookup +import com.android.documentsui.base.RootInfo +import com.android.documentsui.base.UserId +import com.android.documentsui.sorting.SortModel +import com.google.common.util.concurrent.AbstractFuture +import java.io.Closeable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit +import kotlin.time.measureTime + +/** + * A specialization of the BaseFileLoader that searches the set of specified roots. To search + * the roots you must provider: + * + * - The current application context + * - A content lock for which a locking content observer is built + * - A list of user IDs, on whose behalf we query content provider clients. + * - A list of RootInfo objects representing searched roots + * - A query used to search for matching files. + * - Query options such as maximum number of results, last modified time delta, etc. + * - a lookup from file extension to file type + * - The model capable of sorting results + * - An executor for running searches across multiple roots in parallel + * + * SearchLoader requires that either a query is not null and not empty or that QueryOptions + * specify a last modified time restriction. This is to prevent searching for every file + * across every specified root. + */ +class SearchLoader( + context: Context, + userIdList: List<UserId>, + mimeTypeLookup: Lookup<String, String>, + private val mObserver: LockingContentObserver, + private val mRootList: Collection<RootInfo>, + private val mQuery: String?, + private val mOptions: QueryOptions, + private val mSortModel: SortModel, + private val mExecutorService: ExecutorService, +) : BaseFileLoader(context, userIdList, mimeTypeLookup) { + + /** + * Helper class that runs query on a single user for the given parameter. This class implements + * an abstract future so that if the task is completed, we can retrieve the cursor via the get + * method. + */ + inner class SearchTask( + private val mRootId: String, + private val mSearchUri: Uri, + private val mQueryArgs: Bundle, + private val mLatch: CountDownLatch, + ) : Closeable, Runnable, AbstractFuture<Cursor>() { + private var mCursor: Cursor? = null + val cursor: Cursor? get() = mCursor + val taskId: String get() = mSearchUri.toString() + + override fun close() { + mCursor = null + } + + override fun run() { + val queryDuration = measureTime { + try { + mCursor = queryLocation(mRootId, mSearchUri, mQueryArgs, mOptions.maxResults) + set(mCursor) + } finally { + mLatch.countDown() + } + } + Log.d(TAG, "Query on $mSearchUri took $queryDuration") + } + } + + @Volatile + private var mSearchTaskList: List<SearchTask> = listOf() + + // Creates a directory result object corresponding to the current parameters of the loader. + override fun loadInBackground(): DirectoryResult? { + val result = DirectoryResult() + // TODO(b:378590632): If root list has one root use it to construct result.doc + result.doc = DocumentInfo() + result.cursor = emptyCursor() + + val searchedRoots = mRootList + val countDownLatch = CountDownLatch(searchedRoots.size) + val rejectBeforeTimestamp = mOptions.getRejectBeforeTimestamp() + + // Step 1: Build a list of search tasks. + val searchTaskList = + createSearchTaskList(rejectBeforeTimestamp, countDownLatch, mRootList) + Log.d(TAG, "${searchTaskList.size} tasks have been created") + + // Check if we are cancelled; if not copy the task list. + if (isLoadInBackgroundCanceled) { + return result + } + mSearchTaskList = searchTaskList + + // Step 2: Enqueue tasks and wait for them to complete or time out. + for (task in mSearchTaskList) { + mExecutorService.execute(task) + } + Log.d(TAG, "${mSearchTaskList.size} tasks have been enqueued") + + // Step 3: Wait for the results. + try { + if (mOptions.isQueryTimeUnlimited()) { + Log.d(TAG, "Waiting for results with no time limit") + countDownLatch.await() + } else { + Log.d(TAG, "Waiting ${mOptions.maxQueryTime!!.toMillis()}ms for results") + countDownLatch.await( + mOptions.maxQueryTime.toMillis(), + TimeUnit.MILLISECONDS + ) + } + Log.d(TAG, "Waiting for results is done") + } catch (e: InterruptedException) { + Log.d(TAG, "Failed to complete all searches within ${mOptions.maxQueryTime}") + // TODO(b:388336095): Record a metrics indicating incomplete search. + throw RuntimeException(e) + } + + // Step 4: Collect cursors from done tasks. + val cursorList = mutableListOf<Cursor>() + for (task in mSearchTaskList) { + Log.d(TAG, "Processing task ${task.taskId}") + if (isLoadInBackgroundCanceled) { + break + } + // TODO(b:388336095): Record a metric for each done and not done task. + val cursor = task.cursor + if (task.isDone && cursor != null) { + // TODO(b:388336095): Record a metric for null and not null cursor. + Log.d(TAG, "Task ${task.taskId} has ${cursor.count} results") + cursorList.add(cursor) + } + } + Log.d(TAG, "Search complete with ${cursorList.size} cursors collected") + + // Step 5: Assign the cursor, after adding filtering and sorting, to the results. + val mergedCursor = toSingleCursor(cursorList) + mergedCursor.registerContentObserver(mObserver) + val filteringCursor = FilteringCursorWrapper(mergedCursor) + filteringCursor.filterHiddenFiles(mOptions.showHidden) + filteringCursor.filterMimes( + mOptions.acceptableMimeTypes, + if (TextUtils.isEmpty(mQuery)) arrayOf<String>(Document.MIME_TYPE_DIR) else null + ) + if (rejectBeforeTimestamp > 0L) { + filteringCursor.filterLastModified(rejectBeforeTimestamp) + } + result.cursor = mSortModel.sortCursor(filteringCursor, mMimeTypeLookup) + + // TODO(b:388336095): Record the total time it took to complete search. + return result + } + + private fun createContentProviderQuery(root: RootInfo) = + if (TextUtils.isEmpty(mQuery) && mOptions.otherQueryArgs.isEmpty) { + // NOTE: recent document URI does not respect query-arg-mime-types restrictions. Thus + // we only create the recents URI if both the query and other args are empty. + DocumentsContract.buildRecentDocumentsUri( + root.authority, + root.rootId + ) + } else { + // NOTE: We pass empty query, as the name matching query is placed in queryArgs. + DocumentsContract.buildSearchDocumentsUri( + root.authority, + root.rootId, + "" + ) + } + + private fun createQueryArgs(rejectBeforeTimestamp: Long): Bundle { + val queryArgs = Bundle() + mSortModel.addQuerySortArgs(queryArgs) + if (rejectBeforeTimestamp > 0L) { + queryArgs.putLong( + DocumentsContract.QUERY_ARG_LAST_MODIFIED_AFTER, + rejectBeforeTimestamp + ) + } + if (!TextUtils.isEmpty(mQuery)) { + queryArgs.putString(DocumentsContract.QUERY_ARG_DISPLAY_NAME, mQuery) + } + queryArgs.putAll(mOptions.otherQueryArgs) + return queryArgs + } + + /** + * Helper function that creates a list of search tasks for the given countdown latch. + */ + private fun createSearchTaskList( + rejectBeforeTimestamp: Long, + countDownLatch: CountDownLatch, + rootList: Collection<RootInfo> + ): List<SearchTask> { + val searchTaskList = mutableListOf<SearchTask>() + for (root in rootList) { + if (isLoadInBackgroundCanceled) { + break + } + val rootSearchUri = createContentProviderQuery(root) + // TODO(b:385789236): Correctly pass sort order information. + val queryArgs = createQueryArgs(rejectBeforeTimestamp) + mSortModel.addQuerySortArgs(queryArgs) + Log.d(TAG, "Query $rootSearchUri and queryArgs $queryArgs") + val task = SearchTask( + root.rootId, + rootSearchUri, + queryArgs, + countDownLatch + ) + searchTaskList.add(task) + } + return searchTaskList + } + + override fun onReset() { + for (task in mSearchTaskList) { + task.close() + } + Log.d(TAG, "Resetting search loader; search task list emptied.") + super.onReset() + } +} diff --git a/src/com/android/documentsui/picker/ActionHandler.java b/src/com/android/documentsui/picker/ActionHandler.java index 4ea7bbc2d..553fa6986 100644 --- a/src/com/android/documentsui/picker/ActionHandler.java +++ b/src/com/android/documentsui/picker/ActionHandler.java @@ -272,6 +272,9 @@ class ActionHandler<T extends FragmentActivity & Addons> extends AbstractActionH private void onLastAccessedStackLoaded(@Nullable DocumentStack stack) { if (stack == null) { loadDefaultLocation(); + } else if (shouldPreemptivelyRestrictRequestedInitialUri(stack.peek().getDocumentUri())) { + // If the last accessed stack has restricted uri, load default location + loadDefaultLocation(); } else { mState.stack.reset(stack); mActivity.refreshCurrentRootAndDirectory(AnimationView.ANIM_NONE); diff --git a/src/com/android/documentsui/picker/PickActivity.java b/src/com/android/documentsui/picker/PickActivity.java index e9b91b1a0..68a797397 100644 --- a/src/com/android/documentsui/picker/PickActivity.java +++ b/src/com/android/documentsui/picker/PickActivity.java @@ -21,6 +21,7 @@ import static com.android.documentsui.base.State.ACTION_GET_CONTENT; import static com.android.documentsui.base.State.ACTION_OPEN; import static com.android.documentsui.base.State.ACTION_OPEN_TREE; import static com.android.documentsui.base.State.ACTION_PICK_COPY_DESTINATION; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.content.Intent; import android.content.res.Resources; @@ -127,12 +128,15 @@ public class PickActivity extends BaseActivity implements ActionHandler.Addons { new DirectoryDetails(this), mInjector.getModel()::getItemCount); - mInjector.actionModeController = new ActionModeController( - this, - mInjector.selectionMgr, - mNavigator, - mInjector.menuManager, - mInjector.messages); + if (!isUseMaterial3FlagEnabled()) { + mInjector.actionModeController = + new ActionModeController( + this, + mInjector.selectionMgr, + mNavigator, + mInjector.menuManager, + mInjector.messages); + } mInjector.profileTabsController = new ProfileTabsController( mInjector.selectionMgr, @@ -249,6 +253,15 @@ public class PickActivity extends BaseActivity implements ActionHandler.Addons { RootsFragment.show(getSupportFragmentManager(), /* includeApps= */ mState.action == ACTION_GET_CONTENT, /* intent= */ moreApps); + if (isUseMaterial3FlagEnabled()) { + View navRailRoots = findViewById(R.id.nav_rail_container_roots); + if (navRailRoots != null) { + // Medium layout, populate navigation rail layout. + RootsFragment.showNavRail(getSupportFragmentManager(), + /* includeApps= */ mState.action == ACTION_GET_CONTENT, + /* intent= */ moreApps); + } + } } } diff --git a/src/com/android/documentsui/picker/TrampolineActivity.kt b/src/com/android/documentsui/picker/TrampolineActivity.kt new file mode 100644 index 000000000..dba09f2f3 --- /dev/null +++ b/src/com/android/documentsui/picker/TrampolineActivity.kt @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.documentsui.picker + +import android.content.ComponentName +import android.content.Intent +import android.content.Intent.ACTION_GET_CONTENT +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.ext.SdkExtensions +import android.provider.MediaStore.ACTION_PICK_IMAGES +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import com.android.documentsui.base.SharedMinimal.DEBUG + +/** + * DocumentsUI PickActivity currently defers picking of media mime types to the Photopicker. This + * activity trampolines the intent to either Photopicker or to the PickActivity depending on whether + * there are non-media mime types to handle. + */ +class TrampolineActivity : AppCompatActivity() { + companion object { + const val TAG = "TrampolineActivity" + } + + override fun onCreate(savedInstanceBundle: Bundle?) { + super.onCreate(savedInstanceBundle) + + // This activity should not be present in the back stack nor should handle any of the + // corresponding results when picking items. + intent?.apply { + addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT) + addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP) + } + + // In the event there is no photopicker returned, just refer to DocumentsUI. + val photopickerComponentName = getPhotopickerComponentName(intent.type) + if (photopickerComponentName == null) { + forwardIntentToDocumentsUI() + return + } + + // The Photopicker has an entry point to take them back to DocumentsUI. In the event the + // user originated from Photopicker, we don't want to send them back. + val referredFromPhotopicker = referrer?.host == photopickerComponentName.packageName + if (referredFromPhotopicker || !shouldForwardIntentToPhotopicker(intent)) { + forwardIntentToDocumentsUI() + return + } + + // Forward intent to Photopicker. + intent.setComponent(photopickerComponentName) + startActivity(intent) + finish() + } + + private fun forwardIntentToDocumentsUI() { + intent.setClass(applicationContext, PickActivity::class.java) + startActivity(intent) + finish() + } + + private fun getPhotopickerComponentName(type: String?): ComponentName? { + // Intent.ACTION_PICK_IMAGES is only available from SdkExtensions v2 onwards. Prior to that + // the Photopicker was not available, so in those cases should always send to DocumentsUI. + if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 2) { + return null + } + + // Attempt to resolve the `ACTION_PICK_IMAGES` intent to get the Photopicker package. + // On T+ devices this is is a standalone package, whilst prior to T it is part of the + // MediaProvider module. + val pickImagesIntent = Intent( + ACTION_PICK_IMAGES + ).apply { addCategory(Intent.CATEGORY_DEFAULT) } + val photopickerComponentName: ComponentName? = pickImagesIntent.resolveActivity( + packageManager + ) + + // For certain devices the activity that handles ACTION_GET_CONTENT can be disabled (when + // the ACTION_PICK_IMAGES is enabled) so double check by explicitly checking the + // ACTION_GET_CONTENT activity on the same activity that handles ACTION_PICK_IMAGES. + val photopickerGetContentIntent = Intent(ACTION_GET_CONTENT).apply { + setType(type) + setPackage(photopickerComponentName?.packageName) + } + val photopickerGetContentComponent: ComponentName? = + photopickerGetContentIntent.resolveActivity(packageManager) + + // Ensure the `ACTION_GET_CONTENT` activity is enabled. + if (!isComponentEnabled(photopickerGetContentComponent)) { + if (DEBUG) { + Log.d(TAG, "Photopicker PICK_IMAGES component has no enabled GET_CONTENT handler") + } + return null + } + + return photopickerGetContentComponent + } + + private fun isComponentEnabled(componentName: ComponentName?): Boolean { + if (componentName == null) { + return false + } + + return when (packageManager.getComponentEnabledSetting(componentName)) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED -> true + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT -> { + // DEFAULT is a state that essentially defers to the state defined in the + // AndroidManifest which can be either enabled or disabled. + packageManager.getPackageInfo( + componentName.packageName, + PackageManager.GET_ACTIVITIES + )?.let { packageInfo: PackageInfo -> + if (packageInfo.activities == null) { + return false + } + for (val info in packageInfo.activities) { + if (info.name == componentName.className) { + return info.enabled + } + } + } + return false + } + + // Everything else is considered disabled. + else -> false + } + } +} + +fun shouldForwardIntentToPhotopicker(intent: Intent): Boolean { + // Photopicker can only handle `ACTION_GET_CONTENT` intents. + if (intent.action != ACTION_GET_CONTENT) { + return false + } + + // Photopicker only handles media mime types (i.e. image/* or video/*), however, it also handles + // requests that have type */* with EXTRA_MIME_TYPES that are media mime types. In that scenario + // it provides an escape hatch to the user to go back to DocumentsUI. + val intentTypeIsMedia = isMediaMimeType(intent.type) + if (!intentTypeIsMedia && intent.type != "*/*") { + return false + } + + val extraMimeTypes = intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES) + + // In the event there were no `EXTRA_MIME_TYPES` this should exclusively be handled by + // DocumentsUI and not Photopicker. + if (intent.type == "*/*" && extraMimeTypes == null) { + return false + } + + if (extraMimeTypes == null) { + return intentTypeIsMedia + } + + return extraMimeTypes.isNotEmpty() && extraMimeTypes.none { !isMediaMimeType(it) } +} + +fun isMediaMimeType(mimeType: String?): Boolean { + return mimeType?.let { mimeType -> + mimeType.startsWith("image/") || mimeType.startsWith("video/") + } == true +} diff --git a/src/com/android/documentsui/queries/SearchChipViewManager.java b/src/com/android/documentsui/queries/SearchChipViewManager.java index 3dbc6ff74..f673b7408 100644 --- a/src/com/android/documentsui/queries/SearchChipViewManager.java +++ b/src/com/android/documentsui/queries/SearchChipViewManager.java @@ -16,7 +16,7 @@ package com.android.documentsui.queries; -import static com.android.documentsui.flags.Flags.useMaterial3; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.animation.ObjectAnimator; import android.content.Context; @@ -377,6 +377,7 @@ public class SearchChipViewManager { /** * When the chip is focused, adding a focus ring indicator using Stroke. + * TODO(b/381957932): Remove this once Material Chip supports focus ring. */ private void onChipFocusChange(View v, boolean hasFocus) { Chip chip = (Chip) v; @@ -394,21 +395,11 @@ public class SearchChipViewManager { final Context context = mChipGroup.getContext(); chip.setTag(chipData); chip.setText(context.getString(chipData.getTitleRes())); - Drawable chipIcon; - if (chipData.getChipType() == TYPE_LARGE_FILES) { - chipIcon = context.getDrawable(R.drawable.ic_chip_large_files); - } else if (chipData.getChipType() == TYPE_FROM_THIS_WEEK) { - chipIcon = context.getDrawable(R.drawable.ic_chip_from_this_week); - } else if (chipData.getChipType() == TYPE_DOCUMENTS) { - chipIcon = IconUtils.loadMimeIcon(context, MimeTypes.GENERIC_TYPE); - } else { - // get the icon drawable with the first mimeType in chipData - chipIcon = IconUtils.loadMimeIcon(context, chipData.getMimeTypes()[0]); - } + Drawable chipIcon = getChipIcon(chipData); chip.setChipIcon(chipIcon); chip.setOnClickListener(this::onChipClick); - if (useMaterial3()) { + if (isUseMaterial3FlagEnabled()) { chip.setOnFocusChangeListener(this::onChipFocusChange); } @@ -417,6 +408,35 @@ public class SearchChipViewManager { } } + private Drawable getChipIcon(SearchChipData chipData) { + final Context context = mChipGroup.getContext(); + int chipType = chipData.getChipType(); + if (chipType == TYPE_LARGE_FILES) { + return context.getDrawable(R.drawable.ic_chip_large_files); + } + if (chipType == TYPE_FROM_THIS_WEEK) { + return context.getDrawable(R.drawable.ic_chip_from_this_week); + } + + // When use_material3 flag is ON, we don't want to use MIME type icons for + // image/audio/video/document from the system. + if (isUseMaterial3FlagEnabled()) { + return switch (chipType) { + case TYPE_IMAGES -> context.getDrawable(R.drawable.ic_chip_image); + case TYPE_AUDIO -> context.getDrawable(R.drawable.ic_chip_audio); + case TYPE_VIDEOS -> context.getDrawable(R.drawable.ic_chip_video); + case TYPE_DOCUMENTS -> context.getDrawable(R.drawable.ic_chip_document); + default -> null; + }; + } + + if (chipType == TYPE_DOCUMENTS) { + return IconUtils.loadMimeIcon(context, MimeTypes.GENERIC_TYPE); + } + // get the icon drawable with the first mimeType in chipData + return IconUtils.loadMimeIcon(context, chipData.getMimeTypes()[0]); + } + /** * Reorder the chips in chip group. The checked chip has higher order. * @@ -448,19 +468,19 @@ public class SearchChipViewManager { } final int chipSpacing = - useMaterial3() + isUseMaterial3FlagEnabled() ? ((ChipGroup) mChipGroup).getChipSpacingHorizontal() : mChipGroup .getResources() .getDimensionPixelSize(R.dimen.search_chip_spacing); final boolean isRtl = mChipGroup.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL; - final float chipMarginStartEnd = - useMaterial3() - ? 0 + final float chipGroupPaddingStart = + isUseMaterial3FlagEnabled() + ? mChipGroup.getPaddingStart() : mChipGroup .getResources() .getDimensionPixelSize(R.dimen.search_chip_half_spacing); - float lastX = isRtl ? mChipGroup.getWidth() - chipMarginStartEnd : chipMarginStartEnd; + float lastX = isRtl ? mChipGroup.getWidth() - chipGroupPaddingStart : chipGroupPaddingStart; // remove all chips except current clicked chip to avoid losing // accessibility focus. diff --git a/src/com/android/documentsui/queries/SearchViewManager.java b/src/com/android/documentsui/queries/SearchViewManager.java index 053dc93c8..ca132a187 100644 --- a/src/com/android/documentsui/queries/SearchViewManager.java +++ b/src/com/android/documentsui/queries/SearchViewManager.java @@ -20,6 +20,7 @@ import static com.android.documentsui.base.SharedMinimal.DEBUG; import static com.android.documentsui.base.State.ACTION_GET_CONTENT; import static com.android.documentsui.base.State.ACTION_OPEN; import static com.android.documentsui.base.State.ActionType; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.content.Intent; import android.os.Bundle; @@ -332,7 +333,14 @@ public class SearchViewManager implements } // Recent root show open search bar, do not show duplicate search icon. - mMenuItem.setVisible(supportsSearch && (!stack.isRecents() || !mShowSearchBar)); + boolean enabled = supportsSearch && (!stack.isRecents() || !mShowSearchBar); + mMenuItem.setVisible(enabled); + if (isUseMaterial3FlagEnabled()) { + // When the use_material3 flag is enabled, we inflate and deflate the menu. + // This causes the search button to be disabled on inflation, toggle it in + // this scenario. + mMenuItem.setEnabled(enabled); + } mChipViewManager.setChipsRowVisible(supportsSearch && root.supportsMimeTypesSearch()); } diff --git a/src/com/android/documentsui/services/CompressJob.java b/src/com/android/documentsui/services/CompressJob.java index e9ba6e4c8..ccb3ee835 100644 --- a/src/com/android/documentsui/services/CompressJob.java +++ b/src/com/android/documentsui/services/CompressJob.java @@ -24,11 +24,13 @@ import android.app.Notification; import android.app.Notification.Builder; import android.content.ContentResolver; import android.content.Context; +import android.icu.text.MessageFormat; import android.net.Uri; import android.os.Messenger; import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.provider.DocumentsContract; +import android.text.BidiFormatter; import android.util.Log; import com.android.documentsui.R; @@ -40,6 +42,9 @@ import com.android.documentsui.base.UserId; import com.android.documentsui.clipping.UrisSupplier; import java.io.FileNotFoundException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; // TODO: Stop extending CopyJob. final class CompressJob extends CopyJob { @@ -87,6 +92,26 @@ final class CompressJob extends CopyJob { } @Override + protected String getProgressMessage() { + switch (getState()) { + case Job.STATE_SET_UP: + case Job.STATE_COMPLETED: + case Job.STATE_CANCELED: + Map<String, Object> formatArgs = new HashMap<>(); + formatArgs.put("count", mResolvedDocs.size()); + if (mResolvedDocs.size() == 1) { + formatArgs.put("filename", BidiFormatter.getInstance().unicodeWrap( + mResolvedDocs.get(0).displayName)); + } + return (new MessageFormat( + service.getString(R.string.compress_in_progress), Locale.getDefault())) + .format(formatArgs); + default: + return ""; + } + } + + @Override public boolean setUp() { if (!super.setUp()) { return false; @@ -115,11 +140,11 @@ final class CompressJob extends CopyJob { mArchiveUri, ParcelFileDescriptor.MODE_WRITE_ONLY), UserId.DEFAULT_USER); ArchivesProvider.acquireArchive(getClient(mDstInfo), mDstInfo.derivedUri); } catch (FileNotFoundException e) { - Log.e(TAG, "Failed to create dstInfo.", e); + Log.e(TAG, "Cannot create document info", e); failureCount = mResourceUris.getItemCount(); return false; } catch (RemoteException e) { - Log.e(TAG, "Failed to acquire the archive.", e); + Log.e(TAG, "Cannot acquire archive", e); failureCount = mResourceUris.getItemCount(); return false; } @@ -132,7 +157,7 @@ final class CompressJob extends CopyJob { try { ArchivesProvider.releaseArchive(getClient(mDstInfo), mDstInfo.derivedUri); } catch (RemoteException e) { - Log.e(TAG, "Failed to release the archive."); + Log.e(TAG, "Cannot release archive", e); } // Remove the archive file in case of an error. @@ -141,7 +166,7 @@ final class CompressJob extends CopyJob { DocumentsContract.deleteDocument(wrap(getClient(mArchiveUri)), mArchiveUri); } } catch (RemoteException | FileNotFoundException e) { - Log.w(TAG, "Failed to cleanup after compress error: " + mDstInfo.toString(), e); + Log.w(TAG, "Cannot clean up after compress error: " + mDstInfo.toString(), e); } super.finish(); diff --git a/src/com/android/documentsui/services/CopyJob.java b/src/com/android/documentsui/services/CopyJob.java index c972c33ef..9fb3f5d09 100644 --- a/src/com/android/documentsui/services/CopyJob.java +++ b/src/com/android/documentsui/services/CopyJob.java @@ -46,6 +46,7 @@ import android.content.Intent; import android.content.res.AssetFileDescriptor; import android.database.ContentObserver; import android.database.Cursor; +import android.icu.text.MessageFormat; import android.net.Uri; import android.os.DeadObjectException; import android.os.FileUtils; @@ -66,6 +67,7 @@ import android.system.Int64Ref; import android.system.Os; import android.system.OsConstants; import android.system.StructStat; +import android.text.BidiFormatter; import android.util.ArrayMap; import android.util.Log; import android.webkit.MimeTypeMap; @@ -93,6 +95,8 @@ import java.io.InputStream; import java.io.SyncFailedException; import java.text.NumberFormat; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; @@ -195,6 +199,49 @@ class CopyJob extends ResolvedResourcesJob { return warningBuilder.build(); } + protected String getProgressMessage() { + switch (getState()) { + case Job.STATE_SET_UP: + case Job.STATE_COMPLETED: + case Job.STATE_CANCELED: + Map<String, Object> formatArgs = new HashMap<>(); + formatArgs.put("count", mResolvedDocs.size()); + formatArgs.put("directory", + BidiFormatter.getInstance().unicodeWrap(mDstInfo.displayName)); + if (mResolvedDocs.size() == 1) { + formatArgs.put("filename", + BidiFormatter.getInstance().unicodeWrap( + mResolvedDocs.get(0).displayName)); + } + return (new MessageFormat( + service.getString(R.string.copy_in_progress), Locale.getDefault())) + .format(formatArgs); + + default: + return ""; + } + } + + @Override + JobProgress getJobProgress() { + if (mProgressTracker == null) { + return new JobProgress( + id, + getState(), + getProgressMessage(), + hasFailures()); + } + mProgressTracker.updateEstimateRemainingTime(); + return new JobProgress( + id, + getState(), + getProgressMessage(), + hasFailures(), + mProgressTracker.getCurrentBytes(), + mProgressTracker.getRequiredBytes(), + mProgressTracker.getRemainingTimeEstimate()); + } + @Override boolean setUp() { if (!super.setUp()) { @@ -986,6 +1033,10 @@ class CopyJob extends ResolvedResourcesJob { return -1; } + protected long getCurrentBytes() { + return -1; + } + protected void start() { mStartTime = mElapsedRealTimeSupplier.getAsLong(); } @@ -1058,6 +1109,16 @@ class CopyJob extends ResolvedResourcesJob { } @Override + protected long getRequiredBytes() { + return mBytesRequired; + } + + @Override + protected long getCurrentBytes() { + return mBytesCopied.get(); + } + + @Override public void onBytesCopied(long numBytes) { mBytesCopied.getAndAdd(numBytes); } diff --git a/src/com/android/documentsui/services/DeleteJob.java b/src/com/android/documentsui/services/DeleteJob.java index ede46a937..801cc6dd3 100644 --- a/src/com/android/documentsui/services/DeleteJob.java +++ b/src/com/android/documentsui/services/DeleteJob.java @@ -23,7 +23,9 @@ import android.app.Notification; import android.app.Notification.Builder; import android.content.ContentResolver; import android.content.Context; +import android.icu.text.MessageFormat; import android.net.Uri; +import android.text.BidiFormatter; import android.util.Log; import com.android.documentsui.MetricConsts; @@ -36,6 +38,9 @@ import com.android.documentsui.base.UserId; import com.android.documentsui.clipping.UrisSupplier; import java.io.FileNotFoundException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; import javax.annotation.Nullable; @@ -97,6 +102,34 @@ final class DeleteJob extends ResolvedResourcesJob { throw new UnsupportedOperationException(); } + private String getProgressMessage() { + switch (getState()) { + case Job.STATE_SET_UP: + case Job.STATE_COMPLETED: + case Job.STATE_CANCELED: + Map<String, Object> formatArgs = new HashMap<>(); + formatArgs.put("count", mResolvedDocs.size()); + if (mResolvedDocs.size() == 1) { + formatArgs.put("filename", BidiFormatter.getInstance().unicodeWrap( + mResolvedDocs.get(0).displayName)); + } + return (new MessageFormat( + service.getString(R.string.delete_in_progress), Locale.getDefault())) + .format(formatArgs); + default: + return ""; + } + } + + @Override + JobProgress getJobProgress() { + return new JobProgress( + id, + getState(), + getProgressMessage(), + hasFailures()); + } + @Override void start() { ContentResolver resolver = appContext.getContentResolver(); diff --git a/src/com/android/documentsui/services/FileOperationService.java b/src/com/android/documentsui/services/FileOperationService.java index c7be5f4fc..dcb2c1db4 100644 --- a/src/com/android/documentsui/services/FileOperationService.java +++ b/src/com/android/documentsui/services/FileOperationService.java @@ -17,6 +17,7 @@ package com.android.documentsui.services; import static com.android.documentsui.base.SharedMinimal.DEBUG; +import static com.android.documentsui.util.FlagUtils.isVisualSignalsFlagEnabled; import android.app.Notification; import android.app.NotificationChannel; @@ -126,9 +127,15 @@ public class FileOperationService extends Service implements Job.Listener { // Use a features to determine if notification channel is enabled. @VisibleForTesting Features features; + // Used so tests can force the state of visual signals. + @VisibleForTesting Boolean mVisualSignalsEnabled = isVisualSignalsFlagEnabled(); + @GuardedBy("mJobs") private final Map<String, JobRecord> mJobs = new LinkedHashMap<>(); + // Used to send periodic broadcasts for job progress. + private GlobalJobMonitor mJobMonitor; + // The job whose notification is used to keep the service in foreground mode. @GuardedBy("mJobs") private Job mForegroundJob; @@ -162,6 +169,10 @@ public class FileOperationService extends Service implements Job.Listener { notificationManager = getSystemService(NotificationManager.class); } + if (mVisualSignalsEnabled && mJobMonitor == null) { + mJobMonitor = new GlobalJobMonitor(); + } + UserManager userManager = (UserManager) getSystemService(Context.USER_SERVICE); features = new Features.RuntimeFeatures(getResources(), userManager); setUpNotificationChannel(); @@ -188,6 +199,10 @@ public class FileOperationService extends Service implements Job.Listener { Log.d(TAG, "Shutting down executor."); } + if (mJobMonitor != null) { + mJobMonitor.stop(); + } + List<Runnable> unfinishedCopies = executor.shutdownNow(); List<Runnable> unfinishedDeletions = deletionExecutor.shutdownNow(); List<Runnable> unfinished = @@ -330,6 +345,10 @@ public class FileOperationService extends Service implements Job.Listener { assert(record != null); record.job.cleanup(); + if (mVisualSignalsEnabled && mJobs.isEmpty()) { + mJobMonitor.stop(); + } + // Delay the shutdown until we've cleaned up all notifications. shutdown() is now posted in // onFinished(Job job) to main thread. } @@ -389,8 +408,12 @@ public class FileOperationService extends Service implements Job.Listener { } // Set up related monitor - JobMonitor monitor = new JobMonitor(job); - monitor.start(); + if (mVisualSignalsEnabled) { + mJobMonitor.start(); + } else { + JobMonitor monitor = new JobMonitor(job); + monitor.start(); + } } @Override @@ -399,6 +422,9 @@ public class FileOperationService extends Service implements Job.Listener { if (DEBUG) { Log.d(TAG, "onFinished: " + job.id); } + if (mVisualSignalsEnabled) { + mJobMonitor.sendProgress(); + } synchronized (mJobs) { // Delete the job from mJobs first to avoid this job being selected as the foreground @@ -545,6 +571,52 @@ public class FileOperationService extends Service implements Job.Listener { } } + /** + * A class used to periodically poll the state of every running job. + * + * We need to be sending the progress of every job, so rather than having a single monitor per + * job, have one for the whole service. + */ + private final class GlobalJobMonitor implements Runnable { + private static final long PROGRESS_INTERVAL_MILLIS = 500L; + private boolean mRunning = false; + + private void start() { + if (!mRunning) { + handler.post(this); + } + mRunning = true; + } + + private void stop() { + mRunning = false; + handler.removeCallbacks(this); + } + + private void sendProgress() { + var progress = new ArrayList<JobProgress>(); + synchronized (mJobs) { + for (JobRecord rec : mJobs.values()) { + progress.add(rec.job.getJobProgress()); + } + } + Intent intent = new Intent(); + intent.setPackage(getPackageName()); + intent.setAction("com.android.documentsui.PROGRESS"); + intent.putExtra("id", 0); + intent.putParcelableArrayListExtra("progress", progress); + sendBroadcast(intent); + } + + @Override + public void run() { + sendProgress(); + if (mRunning) { + handler.postDelayed(this, PROGRESS_INTERVAL_MILLIS); + } + } + } + @Override public IBinder onBind(Intent intent) { return null; // Boilerplate. See super#onBind diff --git a/src/com/android/documentsui/services/Job.java b/src/com/android/documentsui/services/Job.java index 71f0ae861..0f432cc19 100644 --- a/src/com/android/documentsui/services/Job.java +++ b/src/com/android/documentsui/services/Job.java @@ -190,6 +190,8 @@ abstract public class Job implements Runnable { abstract Notification getWarningNotification(); + abstract JobProgress getJobProgress(); + Uri getDataUriForIntent(String tag) { return Uri.parse(String.format("data,%s-%s", tag, id)); } diff --git a/src/com/android/documentsui/services/JobProgress.kt b/src/com/android/documentsui/services/JobProgress.kt new file mode 100644 index 000000000..98be92f6a --- /dev/null +++ b/src/com/android/documentsui/services/JobProgress.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.documentsui.services + +import android.os.Parcel +import android.os.Parcelable + +/** + * Represents the current progress on an individual job owned by the FileOperationService. + * JobProgress objects are broadcast from the service to activities in order to update the UI. + */ +data class JobProgress @JvmOverloads constructor( + @JvmField val id: String, + @JvmField @Job.State val state: Int, + @JvmField val msg: String?, + @JvmField val hasFailures: Boolean, + @JvmField val currentBytes: Long = -1, + @JvmField val requiredBytes: Long = -1, + @JvmField val msRemaining: Long = -1, +) : Parcelable { + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.apply { + writeString(id) + writeInt(state) + writeString(msg) + writeBoolean(hasFailures) + writeLong(currentBytes) + writeLong(requiredBytes) + writeLong(msRemaining) + } + } + + companion object CREATOR : Parcelable.Creator<JobProgress?> { + override fun createFromParcel(parcel: Parcel): JobProgress? { + return JobProgress( + parcel.readString()!!, + parcel.readInt(), + parcel.readString(), + parcel.readBoolean(), + parcel.readLong(), + parcel.readLong(), + parcel.readLong(), + ) + } + + override fun newArray(size: Int): Array<JobProgress?> { + return arrayOfNulls(size) + } + } +} diff --git a/src/com/android/documentsui/services/MoveJob.java b/src/com/android/documentsui/services/MoveJob.java index ddbe727ac..b2974c5e7 100644 --- a/src/com/android/documentsui/services/MoveJob.java +++ b/src/com/android/documentsui/services/MoveJob.java @@ -24,12 +24,14 @@ import static com.android.documentsui.services.FileOperationService.OPERATION_MO import android.app.Notification; import android.app.Notification.Builder; import android.content.Context; +import android.icu.text.MessageFormat; import android.net.Uri; import android.os.DeadObjectException; import android.os.Messenger; import android.os.RemoteException; import android.provider.DocumentsContract; import android.provider.DocumentsContract.Document; +import android.text.BidiFormatter; import android.util.Log; import com.android.documentsui.MetricConsts; @@ -42,6 +44,9 @@ import com.android.documentsui.base.UserId; import com.android.documentsui.clipping.UrisSupplier; import java.io.FileNotFoundException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; import javax.annotation.Nullable; @@ -94,6 +99,28 @@ final class MoveJob extends CopyJob { } @Override + protected String getProgressMessage() { + switch (getState()) { + case Job.STATE_SET_UP: + case Job.STATE_COMPLETED: + case Job.STATE_CANCELED: + Map<String, Object> formatArgs = new HashMap<>(); + formatArgs.put("count", mResolvedDocs.size()); + formatArgs.put("directory", + BidiFormatter.getInstance().unicodeWrap(mDstInfo.displayName)); + if (mResolvedDocs.size() == 1) { + formatArgs.put("filename", BidiFormatter.getInstance().unicodeWrap( + mResolvedDocs.get(0).displayName)); + } + return (new MessageFormat( + service.getString(R.string.move_in_progress), Locale.getDefault())) + .format(formatArgs); + default: + return ""; + } + } + + @Override public boolean setUp() { if (mSrcParentUri != null) { try { diff --git a/src/com/android/documentsui/services/ResolvedResourcesJob.java b/src/com/android/documentsui/services/ResolvedResourcesJob.java index 500958978..a6001d5f8 100644 --- a/src/com/android/documentsui/services/ResolvedResourcesJob.java +++ b/src/com/android/documentsui/services/ResolvedResourcesJob.java @@ -16,6 +16,10 @@ package com.android.documentsui.services; +import static android.os.SystemClock.uptimeMillis; + +import static com.android.documentsui.base.SharedMinimal.DEBUG; + import android.content.ContentResolver; import android.content.Context; import android.net.Uri; @@ -44,6 +48,9 @@ import java.util.List; public abstract class ResolvedResourcesJob extends Job { private static final String TAG = "ResolvedResourcesJob"; + // Used in logs. + protected final long mStartTime = uptimeMillis(); + final List<DocumentInfo> mResolvedDocs; final List<Uri> mAcquiredArchivedUris = new ArrayList<>(); @@ -72,22 +79,22 @@ public abstract class ResolvedResourcesJob extends Job { mAcquiredArchivedUris.add(uri); } } catch (RemoteException e) { - Log.e(TAG, "Failed to acquire an archive."); + Log.e(TAG, "Cannot acquire an archive", e); return false; } } } catch (IOException e) { - Log.e(TAG, "Failed to read list of target resource Uris. Cannot continue.", e); + Log.e(TAG, "Cannot read list of target resource URIs", e); return false; } int docsResolved = buildDocumentList(); if (!isCanceled() && docsResolved < mResourceUris.getItemCount()) { if (docsResolved == 0) { - Log.e(TAG, "Failed to load any documents. Aborting."); + Log.e(TAG, "Cannot load any documents. Aborting."); return false; } else { - Log.e(TAG, "Failed to load some documents. Processing loaded documents only."); + Log.e(TAG, "Cannot load some documents"); } } @@ -101,9 +108,14 @@ public abstract class ResolvedResourcesJob extends Job { try { ArchivesProvider.releaseArchive(getClient(uri), uri); } catch (RemoteException e) { - Log.e(TAG, "Failed to release an archived document."); + Log.e(TAG, "Cannot release an archived document", e); } } + + if (DEBUG) { + Log.d(TAG, String.format("%s %s finished after %d ms", getClass().getSimpleName(), id, + uptimeMillis() - mStartTime)); + } } /** @@ -123,7 +135,7 @@ public abstract class ResolvedResourcesJob extends Job { try { uris = mResourceUris.getUris(appContext); } catch (IOException e) { - Log.e(TAG, "Failed to read list of target resource Uris. Cannot continue.", e); + Log.e(TAG, "Cannot read list of target resource URIs", e); failureCount = this.mResourceUris.getItemCount(); return 0; } @@ -135,8 +147,7 @@ public abstract class ResolvedResourcesJob extends Job { try { doc = DocumentInfo.fromUri(resolver, uri, UserId.DEFAULT_USER); } catch (FileNotFoundException e) { - Log.e(TAG, "Failed to resolve content from Uri: " + uri - + ". Skipping to next resource.", e); + Log.e(TAG, "Cannot resolve content from URI " + uri, e); onResolveFailed(uri); continue; } diff --git a/src/com/android/documentsui/sidebar/AppItem.java b/src/com/android/documentsui/sidebar/AppItem.java index 4bb7257b6..b72e4041c 100644 --- a/src/com/android/documentsui/sidebar/AppItem.java +++ b/src/com/android/documentsui/sidebar/AppItem.java @@ -16,21 +16,22 @@ package com.android.documentsui.sidebar; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + import android.content.Intent; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.os.UserManager; -import android.text.TextUtils; import android.view.View; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.LayoutRes; + import com.android.documentsui.ActionHandler; import com.android.documentsui.IconUtils; import com.android.documentsui.R; import com.android.documentsui.base.UserId; -import com.android.documentsui.dirlist.AppsRowItemData; /** * An {@link Item} for apps that supports some picking actions like @@ -44,7 +45,16 @@ public class AppItem extends Item { private final ActionHandler mActionHandler; public AppItem(ResolveInfo info, String title, UserId userId, ActionHandler actionHandler) { - super(R.layout.item_root, title, getStringId(info), userId); + this(R.layout.item_root, info, title, userId, actionHandler); + } + + public AppItem( + @LayoutRes int layoutId, + ResolveInfo info, + String title, + UserId userId, + ActionHandler actionHandler) { + super(layoutId, title, getStringId(info), userId); this.info = info; mActionHandler = actionHandler; } @@ -84,14 +94,19 @@ public class AppItem extends Item { final ImageView icon = (ImageView) convertView.findViewById(android.R.id.icon); final TextView titleView = (TextView) convertView.findViewById(android.R.id.title); final TextView summary = (TextView) convertView.findViewById(android.R.id.summary); - final View actionIconArea = convertView.findViewById(R.id.action_icon_area); - final ImageView actionIcon = (ImageView) convertView.findViewById(R.id.action_icon); titleView.setText(title); titleView.setContentDescription(userId.getUserBadgedLabel(convertView.getContext(), title)); bindIcon(icon); - bindActionIcon(actionIconArea, actionIcon); + + // When use_material3 flag is ON, we don't show action icon for the app items, do nothing + // here because the icons are hidden by default. + if (!isUseMaterial3FlagEnabled()) { + final View actionIconArea = convertView.findViewById(R.id.action_icon_area); + final ImageView actionIcon = (ImageView) convertView.findViewById(R.id.action_icon); + bindActionIcon(actionIconArea, actionIcon); + } // TODO: match existing summary behavior from disambig dialog summary.setVisibility(View.GONE); diff --git a/src/com/android/documentsui/sidebar/NavRailAppItem.java b/src/com/android/documentsui/sidebar/NavRailAppItem.java new file mode 100644 index 000000000..befddf0aa --- /dev/null +++ b/src/com/android/documentsui/sidebar/NavRailAppItem.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.documentsui.sidebar; + +import android.content.pm.ResolveInfo; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.documentsui.ActionHandler; +import com.android.documentsui.R; +import com.android.documentsui.base.UserId; + +/** + * Similar to {@link AppItem} but only used in the navigation rail. + */ +public class NavRailAppItem extends AppItem { + + public NavRailAppItem( + ResolveInfo info, String title, UserId userId, ActionHandler actionHandler) { + super(R.layout.nav_rail_item_root, info, title, userId, actionHandler); + } + + @Override + public void bindView(View convertView) { + final ImageView icon = convertView.findViewById(android.R.id.icon); + final TextView titleView = convertView.findViewById(android.R.id.title); + + titleView.setText(title); + titleView.setContentDescription(userId.getUserBadgedLabel(convertView.getContext(), title)); + + bindIcon(icon); + } +} diff --git a/src/com/android/documentsui/sidebar/NavRailProfileItem.java b/src/com/android/documentsui/sidebar/NavRailProfileItem.java new file mode 100644 index 000000000..fe69c286f --- /dev/null +++ b/src/com/android/documentsui/sidebar/NavRailProfileItem.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.documentsui.sidebar; + +import android.content.pm.ResolveInfo; +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.documentsui.ActionHandler; +import com.android.documentsui.R; + + +/** + * Similar to {@link ProfileItem} but only used in the navigation rail. + */ +public class NavRailProfileItem extends ProfileItem { + + public NavRailProfileItem(ResolveInfo info, String title, ActionHandler actionHandler) { + super(R.layout.nav_rail_item_root, info, title, actionHandler); + } + + @Override + public void bindView(View convertView) { + final ImageView icon = convertView.findViewById(android.R.id.icon); + final TextView titleView = convertView.findViewById(android.R.id.title); + + titleView.setText(title); + titleView.setContentDescription(userId.getUserBadgedLabel(convertView.getContext(), title)); + + bindIcon(icon); + } +} diff --git a/src/com/android/documentsui/sidebar/NavRailRootAndAppItem.java b/src/com/android/documentsui/sidebar/NavRailRootAndAppItem.java new file mode 100644 index 000000000..1bcc42f5c --- /dev/null +++ b/src/com/android/documentsui/sidebar/NavRailRootAndAppItem.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.documentsui.sidebar; + +import android.content.pm.ResolveInfo; +import android.view.View; + +import com.android.documentsui.ActionHandler; +import com.android.documentsui.R; +import com.android.documentsui.base.RootInfo; + +/** + * Similar to {@link RootAndAppItem} but only used in the navigation rail. + */ +public class NavRailRootAndAppItem extends RootAndAppItem { + + public NavRailRootAndAppItem( + RootInfo root, ResolveInfo info, ActionHandler actionHandler, boolean maybeShowBadge) { + super(R.layout.nav_rail_item_root, root, info, actionHandler, maybeShowBadge); + } + + @Override + public void bindView(View convertView) { + bindIconAndTitle(convertView); + } +} diff --git a/src/com/android/documentsui/sidebar/NavRailRootItem.java b/src/com/android/documentsui/sidebar/NavRailRootItem.java new file mode 100644 index 000000000..3d4042f22 --- /dev/null +++ b/src/com/android/documentsui/sidebar/NavRailRootItem.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.documentsui.sidebar; + + +import android.view.View; + +import com.android.documentsui.ActionHandler; +import com.android.documentsui.R; +import com.android.documentsui.base.RootInfo; + +/** + * Similar to {@link RootItem} but only used in the navigation rail. + */ +public class NavRailRootItem extends RootItem { + + public NavRailRootItem(RootInfo root, ActionHandler actionHandler, boolean maybeShowBadge) { + super( + R.layout.nav_rail_item_root, + root, + actionHandler, + "" /* packageName */, + maybeShowBadge); + } + + public NavRailRootItem( + RootInfo root, + ActionHandler actionHandler, + String packageName, + boolean maybeShowBadge) { + super(R.layout.nav_rail_item_root, root, actionHandler, packageName, maybeShowBadge); + } + + @Override + public void bindView(View convertView) { + bindIconAndTitle(convertView); + } +} diff --git a/src/com/android/documentsui/sidebar/ProfileItem.java b/src/com/android/documentsui/sidebar/ProfileItem.java index 15068ad4b..779f54445 100644 --- a/src/com/android/documentsui/sidebar/ProfileItem.java +++ b/src/com/android/documentsui/sidebar/ProfileItem.java @@ -20,6 +20,8 @@ import android.content.pm.ResolveInfo; import android.view.View; import android.widget.ImageView; +import androidx.annotation.LayoutRes; + import com.android.documentsui.ActionHandler; import com.android.documentsui.base.UserId; @@ -32,6 +34,11 @@ class ProfileItem extends AppItem { super(info, title, UserId.CURRENT_USER, actionHandler); } + ProfileItem( + @LayoutRes int layoutId, ResolveInfo info, String title, ActionHandler actionHandler) { + super(layoutId, info, title, UserId.CURRENT_USER, actionHandler); + } + @Override protected void bindIcon(ImageView icon) { icon.setImageResource(com.android.documentsui.R.drawable.ic_user_profile); diff --git a/src/com/android/documentsui/sidebar/RootAndAppItem.java b/src/com/android/documentsui/sidebar/RootAndAppItem.java index b893878f3..8861f6058 100644 --- a/src/com/android/documentsui/sidebar/RootAndAppItem.java +++ b/src/com/android/documentsui/sidebar/RootAndAppItem.java @@ -18,11 +18,11 @@ package com.android.documentsui.sidebar; import android.content.Context; import android.content.pm.ResolveInfo; -import android.os.UserManager; import android.provider.DocumentsProvider; -import android.text.TextUtils; import android.view.View; +import androidx.annotation.LayoutRes; + import com.android.documentsui.ActionHandler; import com.android.documentsui.R; import com.android.documentsui.base.RootInfo; @@ -36,9 +36,18 @@ class RootAndAppItem extends RootItem { public final ResolveInfo resolveInfo; - public RootAndAppItem(RootInfo root, ResolveInfo info, ActionHandler actionHandler, + RootAndAppItem( + RootInfo root, ResolveInfo info, ActionHandler actionHandler, boolean maybeShowBadge) { + this(R.layout.item_root, root, info, actionHandler, maybeShowBadge); + } + + RootAndAppItem( + @LayoutRes int layoutId, + RootInfo root, + ResolveInfo info, + ActionHandler actionHandler, boolean maybeShowBadge) { - super(root, actionHandler, info.activityInfo.packageName, maybeShowBadge); + super(layoutId, root, actionHandler, info.activityInfo.packageName, maybeShowBadge); this.resolveInfo = info; } diff --git a/src/com/android/documentsui/sidebar/RootItem.java b/src/com/android/documentsui/sidebar/RootItem.java index a0a3210f8..af72a5239 100644 --- a/src/com/android/documentsui/sidebar/RootItem.java +++ b/src/com/android/documentsui/sidebar/RootItem.java @@ -16,6 +16,8 @@ package com.android.documentsui.sidebar; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + import android.content.Context; import android.graphics.drawable.Drawable; import android.provider.DocumentsProvider; @@ -28,6 +30,7 @@ import android.view.View; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.LayoutRes; import androidx.annotation.Nullable; import com.android.documentsui.ActionHandler; @@ -38,6 +41,8 @@ import com.android.documentsui.base.DocumentInfo; import com.android.documentsui.base.RootInfo; import com.android.documentsui.base.UserId; +import com.google.android.material.button.MaterialButton; + import java.util.Objects; /** @@ -60,7 +65,16 @@ public class RootItem extends Item { public RootItem(RootInfo root, ActionHandler actionHandler, String packageName, boolean maybeShowBadge) { - super(R.layout.item_root, root.title, getStringId(root), root.userId); + this(R.layout.item_root, root, actionHandler, packageName, maybeShowBadge); + } + + public RootItem( + @LayoutRes int layoutId, + RootInfo root, + ActionHandler actionHandler, + String packageName, + boolean maybeShowBadge) { + super(layoutId, root.title, getStringId(root), root.userId); this.root = root; mActionHandler = actionHandler; mPackageName = packageName; @@ -96,19 +110,36 @@ public class RootItem extends Item { } protected final void bindAction(View view, int visibility, int iconId, String description) { - final ImageView actionIcon = (ImageView) view.findViewById(R.id.action_icon); - final View verticalDivider = view.findViewById(R.id.vertical_divider); - final View actionIconArea = view.findViewById(R.id.action_icon_area); - - verticalDivider.setVisibility(visibility); - actionIconArea.setVisibility(visibility); - actionIconArea.setOnClickListener(visibility == View.VISIBLE ? this::onActionClick : null); - if (description != null) { - actionIconArea.setContentDescription(description); - } - if (iconId > 0) { - actionIcon.setImageDrawable(IconUtils.applyTintColor(view.getContext(), iconId, - R.color.item_action_icon)); + if (isUseMaterial3FlagEnabled()) { + final MaterialButton actionIcon = view.findViewById(R.id.action_icon); + + actionIcon.setVisibility(visibility); + actionIcon.setOnClickListener(visibility == View.VISIBLE ? this::onActionClick : null); + actionIcon.setOnFocusChangeListener( + visibility == View.VISIBLE ? this::onActionIconFocusChange : null); + if (description != null) { + actionIcon.setContentDescription(description); + } + if (iconId > 0) { + actionIcon.setIconResource(iconId); + } + } else { + final ImageView actionIcon = (ImageView) view.findViewById(R.id.action_icon); + final View verticalDivider = view.findViewById(R.id.vertical_divider); + final View actionIconArea = view.findViewById(R.id.action_icon_area); + + verticalDivider.setVisibility(visibility); + actionIconArea.setVisibility(visibility); + actionIconArea.setOnClickListener( + visibility == View.VISIBLE ? this::onActionClick : null); + if (description != null) { + actionIconArea.setContentDescription(description); + } + if (iconId > 0) { + actionIcon.setImageDrawable( + IconUtils.applyTintColor( + view.getContext(), iconId, R.color.item_action_icon)); + } } } @@ -116,6 +147,21 @@ public class RootItem extends Item { RootsFragment.ejectClicked(view, root, mActionHandler); } + /** + * When the action icon is focused, adding a focus ring indicator using Stroke. + * TODO(b/381957932): Remove this once Material Button supports focus ring. + */ + protected void onActionIconFocusChange(View view, boolean hasFocus) { + MaterialButton actionIcon = (MaterialButton) view; + if (hasFocus) { + final int focusRingWidth = + actionIcon.getResources().getDimensionPixelSize(R.dimen.focus_ring_width); + actionIcon.setStrokeWidth(focusRingWidth); + } else { + actionIcon.setStrokeWidth(0); + } + } + protected final void bindIconAndTitle(View view) { bindIcon(view, root.loadDrawerIcon(view.getContext(), mMaybeShowBadge)); bindTitle(view); diff --git a/src/com/android/documentsui/sidebar/RootsAdapter.java b/src/com/android/documentsui/sidebar/RootsAdapter.java index a08637c9d..d689705be 100644 --- a/src/com/android/documentsui/sidebar/RootsAdapter.java +++ b/src/com/android/documentsui/sidebar/RootsAdapter.java @@ -16,12 +16,15 @@ package com.android.documentsui.sidebar; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + import android.app.Activity; import android.os.Looper; import android.view.View; import android.view.View.OnDragListener; import android.view.ViewGroup; import android.widget.ArrayAdapter; +import android.widget.ListView; import com.android.documentsui.R; @@ -83,6 +86,16 @@ class RootsAdapter extends ArrayAdapter<Item> { final Item item = getItem(position); final View view = item.getView(convertView, parent); + if (isUseMaterial3FlagEnabled()) { + // In order to have hover showing on the list item, we need to have + // "android:clickable=true" on the list item level, which will break the click handler + // because it's set at the list level, so here we "bubble up" the item level click + // event to the list level by explicitly calling the "performItemClick" on the list + // level. + view.setOnClickListener( + v -> ((ListView) parent).performItemClick(v, position, getItemId(position))); + } + if (item.isRoot()) { view.setTag(R.id.item_position_tag, position); view.setOnDragListener(mDragListener); diff --git a/src/com/android/documentsui/sidebar/RootsFragment.java b/src/com/android/documentsui/sidebar/RootsFragment.java index 76df696ab..1735f9a29 100644 --- a/src/com/android/documentsui/sidebar/RootsFragment.java +++ b/src/com/android/documentsui/sidebar/RootsFragment.java @@ -19,6 +19,8 @@ package com.android.documentsui.sidebar; import static com.android.documentsui.base.Shared.compareToIgnoreCaseNullable; import static com.android.documentsui.base.SharedMinimal.DEBUG; import static com.android.documentsui.base.SharedMinimal.VERBOSE; +import static com.android.documentsui.util.FlagUtils.isHideRootsOnDesktopFlagEnabled; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; import android.app.admin.DevicePolicyManager; import android.content.Context; @@ -49,6 +51,7 @@ import android.widget.AdapterView.OnItemClickListener; import android.widget.AdapterView.OnItemLongClickListener; import android.widget.ListView; +import androidx.annotation.IdRes; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.annotation.VisibleForTesting; @@ -96,12 +99,23 @@ import java.util.stream.Collectors; /** * Display list of known storage backend roots. + * This fragment will be used in: + * * fixed_layout: as navigation tree (sidebar) + * * drawer_layout: as navigation drawer + * * nav_rail_layout: as navigation drawer and navigation rail. */ public class RootsFragment extends Fragment { private static final String TAG = "RootsFragment"; private static final String EXTRA_INCLUDE_APPS = "includeApps"; private static final String EXTRA_INCLUDE_APPS_INTENT = "includeAppsIntent"; + /** + * A key used to store the container id in the RootFragment. + * RootFragment is used in both navigation drawer and navigation rail, there are 2 instances + * of the fragment rendered on the page, we need to know which one is which to render different + * nav items inside. + */ + private static final String EXTRA_CONTAINER_ID = "containerId"; private static final int CONTEXT_MENU_ITEM_TIMEOUT = 500; private final OnItemClickListener mItemListener = new OnItemClickListener() { @@ -135,41 +149,88 @@ public class RootsFragment extends Fragment { private List<Item> mApplicationItemList; + // Weather the fragment is using nav_rail_container_roots as its container (in nav_rail_layout). + // This will always be false if isUseMaterial3FlagEnabled() flag is off. + private boolean mUseRailAsContainer = false; + + /** + * Show the RootsFragment inside the navigation drawer container. + */ + public static RootsFragment show(FragmentManager fm, boolean includeApps, Intent intent) { + return showWithLayout(R.id.container_roots, fm, includeApps, intent); + } + + /** + * Show the RootsFragment inside the navigation rail container. + */ + public static RootsFragment showNavRail(FragmentManager fm, boolean includeApps, + Intent intent) { + return showWithLayout(R.id.nav_rail_container_roots, fm, includeApps, intent); + } + /** * Shows the {@link RootsFragment}. * + * @param containerId the container id where the {@link RootsFragment} will be rendered into * @param fm the FragmentManager for interacting with fragments associated with this * fragment's activity * @param includeApps if {@code true}, query the intent from the system and include apps in * the {@RootsFragment}. * @param intent the intent to query for package manager */ - public static RootsFragment show(FragmentManager fm, boolean includeApps, Intent intent) { + private static RootsFragment showWithLayout( + @IdRes int containerId, FragmentManager fm, boolean includeApps, Intent intent) { final Bundle args = new Bundle(); args.putBoolean(EXTRA_INCLUDE_APPS, includeApps); args.putParcelable(EXTRA_INCLUDE_APPS_INTENT, intent); + if (isUseMaterial3FlagEnabled()) { + args.putInt(EXTRA_CONTAINER_ID, containerId); + } final RootsFragment fragment = new RootsFragment(); fragment.setArguments(args); final FragmentTransaction ft = fm.beginTransaction(); - ft.replace(R.id.container_roots, fragment); + ft.replace(containerId, fragment); ft.commitAllowingStateLoss(); return fragment; } + /** + * Get the RootsFragment instance for the navigation drawer. + */ public static RootsFragment get(FragmentManager fm) { return (RootsFragment) fm.findFragmentById(R.id.container_roots); } + /** + * Get the RootsFragment instance for the navigation drawer. + */ + public static RootsFragment getNavRail(FragmentManager fm) { + return (RootsFragment) fm.findFragmentById(R.id.nav_rail_container_roots); + } + @Override public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + if (isUseMaterial3FlagEnabled()) { + mUseRailAsContainer = + getArguments() != null + && getArguments().getInt(EXTRA_CONTAINER_ID) + == R.id.nav_rail_container_roots; + } + mInjector = getBaseActivity().getInjector(); - final View view = inflater.inflate(R.layout.fragment_roots, container, false); + final View view = + inflater.inflate( + mUseRailAsContainer + ? R.layout.fragment_nav_rail_roots + : R.layout.fragment_roots, + container, + false); mList = (ListView) view.findViewById(R.id.roots_list); mList.setOnItemClickListener(mItemListener); // ListView does not have right-click specific listeners, so we will have a @@ -312,10 +373,17 @@ public class RootsFragment extends Fragment { if (crossProfileResolveInfo != null && !Features.CROSS_PROFILE_TABS) { // Add profile item if we don't support cross-profile tab. sortedItems.add(new SpacerItem()); - sortedItems.add(new ProfileItem(crossProfileResolveInfo, - crossProfileResolveInfo.loadLabel( - getContext().getPackageManager()).toString(), - mActionHandler)); + if (mUseRailAsContainer) { + sortedItems.add(new NavRailProfileItem(crossProfileResolveInfo, + crossProfileResolveInfo.loadLabel( + getContext().getPackageManager()).toString(), + mActionHandler)); + } else { + sortedItems.add(new ProfileItem(crossProfileResolveInfo, + crossProfileResolveInfo.loadLabel( + getContext().getPackageManager()).toString(), + mActionHandler)); + } } // Disable drawer if only one root @@ -413,16 +481,39 @@ public class RootsFragment extends Fragment { if (root.isExternalStorageHome()) { continue; + } else if (isHideRootsOnDesktopFlagEnabled() + && context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_PC) + && (root.isImages() || root.isVideos() + || root.isDocuments() + || root.isAudio())) { + // Hide Images/Videos/Documents/Audio roots on desktop. + Log.d(TAG, "Hiding " + root); + continue; } else if (root.isLibrary() || root.isDownloads()) { - item = new RootItem(root, mActionHandler, maybeShowBadge); + item = + mUseRailAsContainer + ? new NavRailRootItem(root, mActionHandler, maybeShowBadge) + : new RootItem(root, mActionHandler, maybeShowBadge); librariesBuilder.add(item); } else if (root.isStorage()) { - item = new RootItem(root, mActionHandler, maybeShowBadge); + item = + mUseRailAsContainer + ? new NavRailRootItem(root, mActionHandler, maybeShowBadge) + : new RootItem(root, mActionHandler, maybeShowBadge); storageProvidersBuilder.add(item); } else { - item = new RootItem(root, mActionHandler, - providersAccess.getPackageName(root.userId, root.authority), - maybeShowBadge); + item = + mUseRailAsContainer + ? new NavRailRootItem( + root, + mActionHandler, + providersAccess.getPackageName(root.userId, root.authority), + maybeShowBadge) + : new RootItem( + root, + mActionHandler, + providersAccess.getPackageName(root.userId, root.authority), + maybeShowBadge); otherProviders.add(item); } } @@ -566,8 +657,18 @@ public class RootsFragment extends Fragment { appsMapping.put(userPackage, info); if (!CrossProfileUtils.isCrossProfileIntentForwarderActivity(info)) { - final Item item = new AppItem(info, info.loadLabel(pm).toString(), userId, - mActionHandler); + final Item item = + mUseRailAsContainer + ? new NavRailAppItem( + info, + info.loadLabel(pm).toString(), + userId, + mActionHandler) + : new AppItem( + info, + info.loadLabel(pm).toString(), + userId, + mActionHandler); appItems.put(userPackage, item); if (VERBOSE) Log.v(TAG, "Adding handler app: " + item); } @@ -583,8 +684,12 @@ public class RootsFragment extends Fragment { final Item item; if (resolveInfo != null) { - item = new RootAndAppItem(rootItem.root, resolveInfo, mActionHandler, - maybeShowBadge); + item = + mUseRailAsContainer + ? new NavRailRootAndAppItem( + rootItem.root, resolveInfo, mActionHandler, maybeShowBadge) + : new RootAndAppItem( + rootItem.root, resolveInfo, mActionHandler, maybeShowBadge); appItems.remove(userPackage); } else { item = rootItem; diff --git a/src/com/android/documentsui/sorting/HeaderCell.java b/src/com/android/documentsui/sorting/HeaderCell.java index 43e254e39..7f72f13da 100644 --- a/src/com/android/documentsui/sorting/HeaderCell.java +++ b/src/com/android/documentsui/sorting/HeaderCell.java @@ -16,11 +16,11 @@ package com.android.documentsui.sorting; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + import android.animation.AnimatorInflater; import android.animation.LayoutTransition; import android.animation.ObjectAnimator; -import androidx.annotation.AnimatorRes; -import androidx.annotation.StringRes; import android.content.Context; import android.util.AttributeSet; import android.view.Gravity; @@ -29,14 +29,14 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import androidx.annotation.AnimatorRes; +import androidx.annotation.StringRes; + import com.android.documentsui.R; -import com.android.documentsui.sorting.SortDimension; /** - * A clickable, sortable table header cell layout. - * - * It updates its display when it binds to {@link SortDimension} and changes the status of sorting - * when it's clicked. + * A clickable, sortable table header cell layout. It updates its display when it binds to {@link + * SortDimension} and changes the status of sorting when it's clicked. */ public class HeaderCell extends LinearLayout { @@ -62,7 +62,7 @@ public class HeaderCell extends LinearLayout { setVisibility(dimension.getVisibility()); if (dimension.getVisibility() == View.VISIBLE) { - TextView label = (TextView) findViewById(R.id.label); + TextView label = findViewById(R.id.label); label.setText(dimension.getLabelId()); switch (dimension.getDataType()) { case SortDimension.DATA_TYPE_NUMBER: @@ -77,17 +77,21 @@ public class HeaderCell extends LinearLayout { } if (mCurDirection != dimension.getSortDirection()) { - ImageView arrow = (ImageView) findViewById(R.id.sort_arrow); + ImageView arrow = findViewById(R.id.sort_arrow); switch (dimension.getSortDirection()) { case SortDimension.SORT_DIRECTION_NONE: arrow.setVisibility(View.GONE); break; case SortDimension.SORT_DIRECTION_ASCENDING: - showArrow(arrow, R.animator.arrow_rotate_up, + showArrow( + arrow, + R.animator.arrow_rotate_up, R.string.sort_direction_ascending); break; case SortDimension.SORT_DIRECTION_DESCENDING: - showArrow(arrow, R.animator.arrow_rotate_down, + showArrow( + arrow, + R.animator.arrow_rotate_down, R.string.sort_direction_descending); break; default: @@ -100,6 +104,22 @@ public class HeaderCell extends LinearLayout { } } + /** + * Sets a listener on the sort arrow image. When Material 3 is enabled, the Sort Arrow has + * "android:clickable" set to true (to enable a hover state). This stops the click listener from + * falling through to the cell click listener and thus the sort arrow will need to handle clicks + * itself. + */ + public void setSortArrowListeners( + View.OnClickListener clickListener, + View.OnKeyListener keyListener, + SortDimension dimension) { + ImageView arrow = findViewById(R.id.sort_arrow); + arrow.setTag(dimension); + arrow.setOnKeyListener(keyListener); + arrow.setOnClickListener(clickListener); + } + private void showArrow( ImageView arrow, @AnimatorRes int anim, @StringRes int contentDescriptionId) { arrow.setVisibility(View.VISIBLE); @@ -114,8 +134,13 @@ public class HeaderCell extends LinearLayout { } private void setDataTypeNumber(View label) { - label.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_END); - setGravity(Gravity.CENTER_VERTICAL | Gravity.END); + if (isUseMaterial3FlagEnabled()) { + label.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_START); + setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + } else { + label.setTextAlignment(View.TEXT_ALIGNMENT_VIEW_END); + setGravity(Gravity.CENTER_VERTICAL | Gravity.END); + } } private void setDataTypeString(View label) { diff --git a/src/com/android/documentsui/sorting/TableHeaderController.java b/src/com/android/documentsui/sorting/TableHeaderController.java index 549478cfc..cb72ac916 100644 --- a/src/com/android/documentsui/sorting/TableHeaderController.java +++ b/src/com/android/documentsui/sorting/TableHeaderController.java @@ -16,49 +16,54 @@ package com.android.documentsui.sorting; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + +import android.view.KeyEvent; import android.view.View; import com.android.documentsui.R; import javax.annotation.Nullable; -/** - * View controller for table header that associates header cells in table header and columns. - */ +/** View controller for table header that associates header cells in table header and columns. */ public final class TableHeaderController implements SortController.WidgetController { - private View mTableHeader; - private final HeaderCell mTitleCell; private final HeaderCell mSummaryCell; private final HeaderCell mSizeCell; private final HeaderCell mFileTypeCell; private final HeaderCell mDateCell; - + private final SortModel mModel; // We assign this here porque each method reference creates a new object // instance (which is wasteful). private final View.OnClickListener mOnCellClickListener = this::onCellClicked; + private final View.OnKeyListener mOnCellKeyListener = this::onCellKeyEvent; private final SortModel.UpdateListener mModelListener = this::onModelUpdate; - - private final SortModel mModel; + private final View mTableHeader; private TableHeaderController(SortModel sortModel, View tableHeader) { - assert(sortModel != null); - assert(tableHeader != null); + assert (sortModel != null); + assert (tableHeader != null); mModel = sortModel; mTableHeader = tableHeader; - mTitleCell = (HeaderCell) tableHeader.findViewById(android.R.id.title); - mSummaryCell = (HeaderCell) tableHeader.findViewById(android.R.id.summary); - mSizeCell = (HeaderCell) tableHeader.findViewById(R.id.size); - mFileTypeCell = (HeaderCell) tableHeader.findViewById(R.id.file_type); - mDateCell = (HeaderCell) tableHeader.findViewById(R.id.date); + mTitleCell = tableHeader.findViewById(android.R.id.title); + mSummaryCell = tableHeader.findViewById(android.R.id.summary); + mSizeCell = tableHeader.findViewById(R.id.size); + mFileTypeCell = tableHeader.findViewById(R.id.file_type); + mDateCell = tableHeader.findViewById(R.id.date); onModelUpdate(mModel, SortModel.UPDATE_TYPE_UNSPECIFIED); mModel.addListener(mModelListener); } + /** Creates a TableHeaderController. */ + public static @Nullable TableHeaderController create( + SortModel sortModel, @Nullable View tableHeader) { + return (tableHeader == null) ? null : new TableHeaderController(sortModel, tableHeader); + } + private void onModelUpdate(SortModel model, int updateTypeUnspecified) { bindCell(mTitleCell, SortModel.SORT_DIMENSION_ID_TITLE); bindCell(mSummaryCell, SortModel.SORT_DIMENSION_ID_SUMMARY); @@ -78,7 +83,7 @@ public final class TableHeaderController implements SortController.WidgetControl } private void bindCell(HeaderCell cell, int id) { - assert(cell != null); + assert (cell != null); SortDimension dimension = mModel.getDimensionById(id); cell.setTag(dimension); @@ -87,8 +92,12 @@ public final class TableHeaderController implements SortController.WidgetControl if (dimension.getVisibility() == View.VISIBLE && dimension.getSortCapability() != SortDimension.SORT_CAPABILITY_NONE) { cell.setOnClickListener(mOnCellClickListener); + if (isUseMaterial3FlagEnabled()) { + cell.setSortArrowListeners(mOnCellClickListener, mOnCellKeyListener, dimension); + } } else { cell.setOnClickListener(null); + if (isUseMaterial3FlagEnabled()) cell.setSortArrowListeners(null, null, null); } } @@ -98,8 +107,17 @@ public final class TableHeaderController implements SortController.WidgetControl mModel.sortByUser(dimension.getId(), dimension.getNextDirection()); } - public static @Nullable TableHeaderController create( - SortModel sortModel, @Nullable View tableHeader) { - return (tableHeader == null) ? null : new TableHeaderController(sortModel, tableHeader); + /** Sorts the column if the key pressed was Enter or Space. */ + private boolean onCellKeyEvent(View v, int keyCode, KeyEvent event) { + if (!isUseMaterial3FlagEnabled()) { + return false; + } + // Only the enter and space bar should trigger the sort header to engage. + if (event.getAction() == KeyEvent.ACTION_UP + && (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_SPACE)) { + onCellClicked(v); + return true; + } + return false; } } diff --git a/src/com/android/documentsui/ui/DocumentDebugInfo.java b/src/com/android/documentsui/ui/DocumentDebugInfo.java deleted file mode 100644 index b7712c60a..000000000 --- a/src/com/android/documentsui/ui/DocumentDebugInfo.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.documentsui.ui; - -import androidx.annotation.Nullable; -import android.content.Context; -import android.util.AttributeSet; -import android.widget.TextView; - -import com.android.documentsui.base.DocumentInfo; - -/** - * Document debug info view. - */ -public class DocumentDebugInfo extends TextView { - public DocumentDebugInfo(Context context) { - super(context); - - } - - public DocumentDebugInfo(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public void update(DocumentInfo doc) { - - String dbgInfo = new StringBuilder() - .append("** PROPERTIES **\n\n") - .append("docid: " + doc.documentId).append("\n") - .append("name: " + doc.displayName).append("\n") - .append("mimetype: " + doc.mimeType).append("\n") - .append("container: " + doc.isContainer()).append("\n") - .append("virtual: " + doc.isVirtual()).append("\n") - .append("\n") - .append("** OPERATIONS **\n\n") - .append("create: " + doc.isCreateSupported()).append("\n") - .append("delete: " + doc.isDeleteSupported()).append("\n") - .append("rename: " + doc.isRenameSupported()).append("\n\n") - .append("** URI **\n\n") - .append(doc.derivedUri).append("\n") - .toString(); - - setText(dbgInfo); - } -} diff --git a/src/com/android/documentsui/ui/Snackbars.java b/src/com/android/documentsui/ui/Snackbars.java index b45c247b5..795b6248b 100644 --- a/src/com/android/documentsui/ui/Snackbars.java +++ b/src/com/android/documentsui/ui/Snackbars.java @@ -16,6 +16,8 @@ package com.android.documentsui.ui; +import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled; + import android.app.Activity; import android.view.Gravity; import android.view.View; @@ -110,7 +112,10 @@ public final class Snackbars { public static final Snackbar makeSnackbar( Activity activity, CharSequence message, int duration) { - final View view = activity.findViewById(R.id.container_save); + final View view = activity.findViewById(isUseMaterial3FlagEnabled() + ? R.id.coordinator_layout + : R.id.container_save + ); return Snackbar.make(view, message, duration); } } diff --git a/src/com/android/documentsui/util/FlagUtils.kt b/src/com/android/documentsui/util/FlagUtils.kt new file mode 100644 index 000000000..eee51be89 --- /dev/null +++ b/src/com/android/documentsui/util/FlagUtils.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.documentsui.util + +import com.android.documentsui.flags.Flags + +/** + * Wraps the static flags classes to enable a single place to refactor flag usage + * (or combine usage when required). + */ +class FlagUtils { + companion object { + @JvmStatic + fun isUseMaterial3FlagEnabled(): Boolean { + return Flags.useMaterial3() + } + + @JvmStatic + fun isZipNgFlagEnabled(): Boolean { + return Flags.zipNgRo() + } + + @JvmStatic + fun isUseSearchV2RwFlagEnabled(): Boolean { + return Flags.useSearchV2Rw() + } + + @JvmStatic + fun isDesktopFileHandlingFlagEnabled(): Boolean { + return Flags.desktopFileHandlingRo() + } + + @JvmStatic + fun isVisualSignalsFlagEnabled(): Boolean { + return Flags.visualSignalsRo() && isUseMaterial3FlagEnabled() + } + + @JvmStatic + fun isHideRootsOnDesktopFlagEnabled(): Boolean { + return Flags.hideRootsOnDesktopRo() + } + + @JvmStatic + fun isUsePeekPreviewFlagEnabled(): Boolean { + return Flags.usePeekPreviewRo() + } + } +} |