summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/com/android/documentsui/AbstractActionHandler.java105
-rw-r--r--src/com/android/documentsui/ActionHandler.java8
-rw-r--r--src/com/android/documentsui/ActionModeController.java16
-rw-r--r--src/com/android/documentsui/BaseActivity.java163
-rw-r--r--src/com/android/documentsui/DrawerController.java12
-rw-r--r--src/com/android/documentsui/Injector.java5
-rw-r--r--src/com/android/documentsui/MenuManager.java37
-rw-r--r--src/com/android/documentsui/MultiRootDocumentsLoader.java2
-rw-r--r--src/com/android/documentsui/NavigationViewManager.java186
-rw-r--r--src/com/android/documentsui/ProfileTabs.java10
-rw-r--r--src/com/android/documentsui/RecentsLoader.java6
-rw-r--r--src/com/android/documentsui/UserManagerState.java457
-rw-r--r--src/com/android/documentsui/archives/Archive.java99
-rw-r--r--src/com/android/documentsui/archives/ReadableArchive.java6
-rw-r--r--src/com/android/documentsui/base/Menus.java4
-rw-r--r--src/com/android/documentsui/dirlist/AppsRowManager.java7
-rw-r--r--src/com/android/documentsui/dirlist/DirectoryFragment.java79
-rw-r--r--src/com/android/documentsui/dirlist/DocumentHolder.java5
-rw-r--r--src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java38
-rw-r--r--src/com/android/documentsui/dirlist/GridDirectoryHolder.java1
-rw-r--r--src/com/android/documentsui/dirlist/GridDocumentHolder.java111
-rw-r--r--src/com/android/documentsui/dirlist/GridPhotoHolder.java7
-rw-r--r--src/com/android/documentsui/dirlist/IconHelper.java35
-rw-r--r--src/com/android/documentsui/dirlist/ListDocumentHolder.java40
-rw-r--r--src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java9
-rw-r--r--src/com/android/documentsui/dirlist/SelectionMetadata.java2
-rw-r--r--src/com/android/documentsui/files/ActionHandler.java68
-rw-r--r--src/com/android/documentsui/files/FilesActivity.java71
-rw-r--r--src/com/android/documentsui/files/MenuManager.java15
-rw-r--r--src/com/android/documentsui/loaders/BaseFileLoader.kt208
-rw-r--r--src/com/android/documentsui/loaders/FolderLoader.kt82
-rw-r--r--src/com/android/documentsui/loaders/QueryOptions.kt90
-rw-r--r--src/com/android/documentsui/loaders/SearchLoader.kt257
-rw-r--r--src/com/android/documentsui/picker/ActionHandler.java3
-rw-r--r--src/com/android/documentsui/picker/PickActivity.java25
-rw-r--r--src/com/android/documentsui/picker/TrampolineActivity.kt181
-rw-r--r--src/com/android/documentsui/queries/SearchChipViewManager.java56
-rw-r--r--src/com/android/documentsui/queries/SearchViewManager.java10
-rw-r--r--src/com/android/documentsui/services/CompressJob.java33
-rw-r--r--src/com/android/documentsui/services/CopyJob.java61
-rw-r--r--src/com/android/documentsui/services/DeleteJob.java33
-rw-r--r--src/com/android/documentsui/services/FileOperationService.java76
-rw-r--r--src/com/android/documentsui/services/Job.java2
-rw-r--r--src/com/android/documentsui/services/JobProgress.kt69
-rw-r--r--src/com/android/documentsui/services/MoveJob.java27
-rw-r--r--src/com/android/documentsui/services/ResolvedResourcesJob.java27
-rw-r--r--src/com/android/documentsui/sidebar/AppItem.java29
-rw-r--r--src/com/android/documentsui/sidebar/NavRailAppItem.java48
-rw-r--r--src/com/android/documentsui/sidebar/NavRailProfileItem.java47
-rw-r--r--src/com/android/documentsui/sidebar/NavRailRootAndAppItem.java40
-rw-r--r--src/com/android/documentsui/sidebar/NavRailRootItem.java52
-rw-r--r--src/com/android/documentsui/sidebar/ProfileItem.java7
-rw-r--r--src/com/android/documentsui/sidebar/RootAndAppItem.java17
-rw-r--r--src/com/android/documentsui/sidebar/RootItem.java74
-rw-r--r--src/com/android/documentsui/sidebar/RootsAdapter.java13
-rw-r--r--src/com/android/documentsui/sidebar/RootsFragment.java137
-rw-r--r--src/com/android/documentsui/sorting/HeaderCell.java51
-rw-r--r--src/com/android/documentsui/sorting/TableHeaderController.java56
-rw-r--r--src/com/android/documentsui/ui/DocumentDebugInfo.java59
-rw-r--r--src/com/android/documentsui/ui/Snackbars.java7
-rw-r--r--src/com/android/documentsui/util/FlagUtils.kt62
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()
+ }
+ }
+}