summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--res/flag(com.android.documentsui.flags.use_material3)/layout/drawer_layout.xml3
-rw-r--r--res/flag(com.android.documentsui.flags.use_material3)/layout/fixed_layout.xml3
-rw-r--r--res/flag(com.android.documentsui.flags.use_material3)/layout/job_progress_toolbar_item.xml25
-rw-r--r--res/flag(com.android.documentsui.flags.use_material3)/layout/nav_rail_layout.xml3
-rw-r--r--res/flag(com.android.documentsui.flags.use_material3)/layout/search_chip_row.xml21
-rw-r--r--res/flag(com.android.documentsui.flags.use_material3)/menu/activity.xml6
-rw-r--r--res/flag(com.android.documentsui.flags.use_material3)/values/dimens.xml10
-rw-r--r--res/flag(com.android.documentsui.flags.use_material3)/values/styles.xml5
-rw-r--r--src/com/android/documentsui/HorizontalBreadcrumb.java39
-rw-r--r--src/com/android/documentsui/IconUtils.java32
-rw-r--r--src/com/android/documentsui/JobPanelController.kt126
-rw-r--r--src/com/android/documentsui/MenuManager.java9
-rw-r--r--src/com/android/documentsui/NavigationViewManager.java4
-rw-r--r--src/com/android/documentsui/ProfileTabs.java7
-rw-r--r--src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java22
-rw-r--r--src/com/android/documentsui/dirlist/GridDocumentHolder.java14
-rw-r--r--src/com/android/documentsui/dirlist/ListDocumentHolder.java7
-rw-r--r--src/com/android/documentsui/files/MenuManager.java18
-rw-r--r--src/com/android/documentsui/queries/SearchChipViewManager.java2
-rw-r--r--src/com/android/documentsui/services/FileOperationService.java10
-rw-r--r--src/com/android/documentsui/services/Job.java10
-rw-r--r--src/com/android/documentsui/util/ColorUtils.kt36
-rw-r--r--tests/functional/com/android/documentsui/TrampolineActivityTest.kt43
-rw-r--r--tests/unit/com/android/documentsui/JobPanelControllerTest.kt207
-rw-r--r--tests/unit/com/android/documentsui/queries/SearchChipViewManagerTest.java35
25 files changed, 632 insertions, 65 deletions
diff --git a/res/flag(com.android.documentsui.flags.use_material3)/layout/drawer_layout.xml b/res/flag(com.android.documentsui.flags.use_material3)/layout/drawer_layout.xml
index fc4a22bc6..aeb85442f 100644
--- a/res/flag(com.android.documentsui.flags.use_material3)/layout/drawer_layout.xml
+++ b/res/flag(com.android.documentsui.flags.use_material3)/layout/drawer_layout.xml
@@ -83,7 +83,8 @@
android:id="@+id/horizontal_breadcrumb"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:background="?attr/colorSurfaceBright" />
+ android:background="?attr/colorSurfaceBright"
+ android:paddingHorizontal="@dimen/breadcrumb_padding_horizontal" />
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/container_save"
diff --git a/res/flag(com.android.documentsui.flags.use_material3)/layout/fixed_layout.xml b/res/flag(com.android.documentsui.flags.use_material3)/layout/fixed_layout.xml
index 5496f0d84..c2a06122a 100644
--- a/res/flag(com.android.documentsui.flags.use_material3)/layout/fixed_layout.xml
+++ b/res/flag(com.android.documentsui.flags.use_material3)/layout/fixed_layout.xml
@@ -115,7 +115,8 @@
<com.android.documentsui.HorizontalBreadcrumb
android:id="@+id/horizontal_breadcrumb"
android:layout_width="match_parent"
- android:layout_height="wrap_content" />
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="@dimen/breadcrumb_padding_horizontal" />
</LinearLayout>
diff --git a/res/flag(com.android.documentsui.flags.use_material3)/layout/job_progress_toolbar_item.xml b/res/flag(com.android.documentsui.flags.use_material3)/layout/job_progress_toolbar_item.xml
new file mode 100644
index 000000000..222c8d43e
--- /dev/null
+++ b/res/flag(com.android.documentsui.flags.use_material3)/layout/job_progress_toolbar_item.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ 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.
+-->
+
+<com.google.android.material.progressindicator.CircularProgressIndicator
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ style="@style/JobProgressToolbarIndicatorStyle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:clickable="true"
+ android:focusable="true" />
diff --git a/res/flag(com.android.documentsui.flags.use_material3)/layout/nav_rail_layout.xml b/res/flag(com.android.documentsui.flags.use_material3)/layout/nav_rail_layout.xml
index bd85b7dac..bfbb83c17 100644
--- a/res/flag(com.android.documentsui.flags.use_material3)/layout/nav_rail_layout.xml
+++ b/res/flag(com.android.documentsui.flags.use_material3)/layout/nav_rail_layout.xml
@@ -147,7 +147,8 @@
<com.android.documentsui.HorizontalBreadcrumb
android:id="@+id/horizontal_breadcrumb"
android:layout_width="match_parent"
- android:layout_height="wrap_content" />
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="@dimen/breadcrumb_padding_horizontal" />
</LinearLayout>
<androidx.coordinatorlayout.widget.CoordinatorLayout
diff --git a/res/flag(com.android.documentsui.flags.use_material3)/layout/search_chip_row.xml b/res/flag(com.android.documentsui.flags.use_material3)/layout/search_chip_row.xml
index 559ae3188..62a36f3e8 100644
--- a/res/flag(com.android.documentsui.flags.use_material3)/layout/search_chip_row.xml
+++ b/res/flag(com.android.documentsui.flags.use_material3)/layout/search_chip_row.xml
@@ -20,10 +20,21 @@
android:layout_height="wrap_content"
android:scrollbars="none">
- <com.google.android.material.chip.ChipGroup
- android:id="@+id/search_chip_group"
+ <!-- This additional FrameLayout layer is essential to make HorizontalScrollView work with the
+ marginHorizontal on the ChipGroup below, without this the scroll behavior is weird.
+ Alternatively we could use paddingHorizontal on the ChipGroup below to make it work with
+ HorizontalScrollView, but that cause a weird padding issue in RTL.
+ -->
+ <FrameLayout
android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingHorizontal="@dimen/main_container_padding_start"
- android:paddingVertical="@dimen/search_chip_group_padding_vertical" />
+ android:layout_height="wrap_content">
+
+ <com.google.android.material.chip.ChipGroup
+ android:id="@+id/search_chip_group"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginHorizontal="@dimen/main_container_padding_start"
+ android:paddingVertical="@dimen/search_chip_group_padding_vertical" />
+
+ </FrameLayout>
</HorizontalScrollView>
diff --git a/res/flag(com.android.documentsui.flags.use_material3)/menu/activity.xml b/res/flag(com.android.documentsui.flags.use_material3)/menu/activity.xml
index 0e636a18c..809a1386a 100644
--- a/res/flag(com.android.documentsui.flags.use_material3)/menu/activity.xml
+++ b/res/flag(com.android.documentsui.flags.use_material3)/menu/activity.xml
@@ -34,6 +34,12 @@
app:showAsAction="always|collapseActionView"
app:actionViewClass="androidx.appcompat.widget.SearchView"/>
<item
+ android:id="@+id/option_menu_job_progress"
+ android:enabled="false"
+ android:visible="false"
+ app:actionLayout="@layout/job_progress_toolbar_item"
+ app:showAsAction="always" />
+ <item
android:id="@+id/sub_menu_grid"
android:title="@string/menu_grid"
android:icon="@drawable/ic_menu_view_grid"
diff --git a/res/flag(com.android.documentsui.flags.use_material3)/values/dimens.xml b/res/flag(com.android.documentsui.flags.use_material3)/values/dimens.xml
index f9b9eddfd..16f75313c 100644
--- a/res/flag(com.android.documentsui.flags.use_material3)/values/dimens.xml
+++ b/res/flag(com.android.documentsui.flags.use_material3)/values/dimens.xml
@@ -26,6 +26,8 @@
<dimen name="icon_size">40dp</dimen>
<dimen name="button_touch_size">48dp</dimen>
<dimen name="root_icon_size">24dp</dimen>
+ <dimen name="thumbnail_clip_corner_radius">6dp</dimen>
+ <dimen name="thumbnail_border_width">1dp</dimen>
<!-- TODO(b/379776735): remove this block after use_material3 flag is launched. -->
<dimen name="root_icon_margin">0dp</dimen>
<dimen name="root_spacer_padding">0dp</dimen>
@@ -36,7 +38,6 @@
<dimen name="check_icon_size">20dp</dimen>
<dimen name="zoom_icon_size">24dp</dimen>
<dimen name="list_item_thumbnail_size">40dp</dimen>
- <dimen name="list_item_thumbnail_border_width">2dp</dimen>
<dimen name="grid_item_icon_size">30dp</dimen>
<!-- TODO(b/379776735): remove this after use_material3 flag is launched. -->
<dimen name="progress_bar_height">4dp</dimen>
@@ -59,7 +60,12 @@
<dimen name="list_item_icon_padding">16dp</dimen>
<dimen name="list_divider_inset">72dp</dimen>
<!-- block end -->
+ <dimen name="breadcrumb_padding_horizontal">@dimen/space_small_3</dimen>
+ <!-- TODO(b/379776735): remove this after use_material3 flag is launched. -->
<dimen name="breadcrumb_item_padding">8dp</dimen>
+ <dimen name="breadcrumb_item_padding_horizontal">12dp</dimen>
+ <dimen name="breadcrumb_item_padding_vertical">6dp</dimen>
+ <dimen name="breadcrumb_item_arrow_padding">@dimen/space_extra_small_2</dimen>
<dimen name="breadcrumb_item_height">36dp</dimen>
<dimen name="dir_elevation">8dp</dimen>
<dimen name="drag_shadow_size">120dp</dimen>
@@ -71,7 +77,6 @@
<dimen name="grid_item_thumbnail_width">80dp</dimen>
<dimen name="grid_item_thumbnail_height">80dp</dimen>
<dimen name="grid_item_thumbnail_radius">12dp</dimen>
- <dimen name="grid_item_thumbnail_border_width">2dp</dimen>
<dimen name="grid_item_icon_width">64dp</dimen>
<dimen name="grid_item_icon_height">64dp</dimen>
<dimen name="grid_item_layout_margin">@dimen/space_small_1</dimen>
@@ -221,6 +226,7 @@
<!-- Main margin is set by main_container_padding_start for the menu button, here is for
the space between the button the text/title. -->
<dimen name="search_bar_text_margin_start">@dimen/space_extra_small_6</dimen>
+ <dimen name="job_progress_toolbar_indicator_size">24dp</dimen>
<!-- The main margin is controlled above on paddingStart, zeroing toolbar_content_insets to
avoid pushing the title or button further. -->
<dimen name="toolbar_content_inset_start">0dp</dimen>
diff --git a/res/flag(com.android.documentsui.flags.use_material3)/values/styles.xml b/res/flag(com.android.documentsui.flags.use_material3)/values/styles.xml
index 5d0687a37..6819c12a3 100644
--- a/res/flag(com.android.documentsui.flags.use_material3)/values/styles.xml
+++ b/res/flag(com.android.documentsui.flags.use_material3)/values/styles.xml
@@ -34,6 +34,11 @@
<item name="android:maxHeight">@dimen/icon_size_headline_large</item>
</style>
+ <style name="JobProgressToolbarIndicatorStyle" parent="@style/Widget.Material3.CircularProgressIndicator.ExtraSmall">
+ <item name="indicatorSize">@dimen/job_progress_toolbar_indicator_size</item>
+ <item name="indicatorInset">@dimen/space_extra_small_6</item>
+ </style>
+
<style name="ToolbarStyles" parent="@style/Widget.Material3.Toolbar">
<item name="android:paddingStart">@dimen/toolbar_padding_start</item>
<item name="android:paddingEnd">@dimen/toolbar_padding_end</item>
diff --git a/src/com/android/documentsui/HorizontalBreadcrumb.java b/src/com/android/documentsui/HorizontalBreadcrumb.java
index cb25479b3..f47f464c0 100644
--- a/src/com/android/documentsui/HorizontalBreadcrumb.java
+++ b/src/com/android/documentsui/HorizontalBreadcrumb.java
@@ -16,6 +16,8 @@
package com.android.documentsui;
+import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled;
+
import android.content.Context;
import android.util.AttributeSet;
import android.view.GestureDetector;
@@ -183,8 +185,6 @@ public final class HorizontalBreadcrumb extends RecyclerView implements Breadcru
@Override
public void onBindViewHolder(BreadcrumbHolder holder, int position) {
- final int padding = (int) holder.itemView.getResources()
- .getDimension(R.dimen.breadcrumb_item_padding);
final boolean isFirst = position == 0;
// Note that when isFirst is true, there might not be a DocumentInfo on the stack as it
// could be an error state screen accessible from the root info.
@@ -193,8 +193,39 @@ public final class HorizontalBreadcrumb extends RecyclerView implements Breadcru
holder.mTitle.setText(
isFirst ? mEnv.getCurrentRoot().title : mState.stack.get(position).displayName);
holder.mTitle.setEnabled(isLast);
- holder.mTitle.setPadding(isFirst ? padding * 3 : padding,
- padding, isLast ? padding * 2 : padding, padding);
+ if (isUseMaterial3FlagEnabled()) {
+ final int paddingHorizontal =
+ (int)
+ holder.itemView
+ .getResources()
+ .getDimension(R.dimen.breadcrumb_item_padding_horizontal);
+ final int paddingVertical =
+ (int)
+ holder.itemView
+ .getResources()
+ .getDimension(R.dimen.breadcrumb_item_padding_vertical);
+ final int arrowPadding =
+ (int)
+ holder.itemView
+ .getResources()
+ .getDimension(R.dimen.breadcrumb_item_arrow_padding);
+ holder.mTitle.setPadding(
+ paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical);
+
+ ViewGroup.MarginLayoutParams params =
+ (ViewGroup.MarginLayoutParams) holder.mArrow.getLayoutParams();
+ params.setMarginStart(arrowPadding);
+ params.setMarginEnd(arrowPadding);
+ holder.mArrow.setLayoutParams(params);
+ } else {
+ final int padding = (int) holder.itemView.getResources()
+ .getDimension(R.dimen.breadcrumb_item_padding);
+ holder.mTitle.setPadding(
+ isFirst ? padding * 3 : padding,
+ padding,
+ isLast ? padding * 2 : padding,
+ padding);
+ }
holder.mArrow.setVisibility(isLast ? View.GONE : View.VISIBLE);
holder.itemView.setOnKeyListener(mClickListener);
diff --git a/src/com/android/documentsui/IconUtils.java b/src/com/android/documentsui/IconUtils.java
index 1763fd2a3..ece38bb64 100644
--- a/src/com/android/documentsui/IconUtils.java
+++ b/src/com/android/documentsui/IconUtils.java
@@ -19,8 +19,12 @@ package com.android.documentsui;
import android.content.Context;
import android.content.pm.PackageManager;
import android.content.pm.ProviderInfo;
+import android.graphics.Outline;
import android.graphics.drawable.Drawable;
import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewOutlineProvider;
+import android.widget.ImageView;
import com.android.documentsui.base.UserId;
@@ -80,4 +84,32 @@ public class IconUtils {
context.getTheme().resolveAttribute(tintAttrId, outValue, true);
return applyTintColor(context, drawableId, outValue.resourceId);
}
+
+ /**
+ * When a ImageView loads a thumbnail from a bitmap, we usually uses a CardView to wrap it to
+ * apply CardView's corner radius to the ImageView. This causes the corner pixelation of the
+ * thumbnail especially when there's a border (stroke) around the CardView. This method creates
+ * a custom clip outline with the correct shape to fix this issue.
+ *
+ * @param imageView ImageView to apply clip outline.
+ * @param strokeWidth stroke width of the thumbnail.
+ * @param cornerRadius corner radius of the thumbnail.
+ */
+ public static void applyThumbnailClipOutline(
+ ImageView imageView, int strokeWidth, int cornerRadius) {
+ ViewOutlineProvider outlineProvider =
+ new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setRoundRect(
+ strokeWidth,
+ strokeWidth,
+ view.getWidth() - strokeWidth,
+ view.getHeight() - strokeWidth,
+ cornerRadius);
+ }
+ };
+ imageView.setOutlineProvider(outlineProvider);
+ imageView.setClipToOutline(true);
+ }
}
diff --git a/src/com/android/documentsui/JobPanelController.kt b/src/com/android/documentsui/JobPanelController.kt
new file mode 100644
index 000000000..b3b5f1cfd
--- /dev/null
+++ b/src/com/android/documentsui/JobPanelController.kt
@@ -0,0 +1,126 @@
+/*
+ * 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
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.util.Log
+import android.view.MenuItem
+import android.widget.ProgressBar
+import com.android.documentsui.base.Menus
+import com.android.documentsui.services.FileOperationService
+import com.android.documentsui.services.FileOperationService.EXTRA_PROGRESS
+import com.android.documentsui.services.Job
+import com.android.documentsui.services.JobProgress
+
+/**
+ * JobPanelController is responsible for receiving broadcast updates from the [FileOperationService]
+ * and updating a given menu item to reflect the current progress.
+ */
+class JobPanelController(private val mContext: Context) : BroadcastReceiver() {
+ companion object {
+ private const val TAG = "JobPanelController"
+ private const val MAX_PROGRESS = 100
+ }
+
+ private enum class State {
+ INVISIBLE, INDETERMINATE, VISIBLE
+ }
+
+ /** The current state of the menu progress item. */
+ private var mState = State.INVISIBLE
+
+ /** The total progress from 0 to MAX_PROGRESS. */
+ private var mTotalProgress = 0
+
+ /** List of jobs currently tracked by this class. */
+ private val mCurrentJobs = LinkedHashMap<String, JobProgress>()
+
+ /** Current menu item being controlled by this class. */
+ private var mMenuItem: MenuItem? = null
+
+ init {
+ val filter = IntentFilter(FileOperationService.ACTION_PROGRESS)
+ mContext.registerReceiver(this, filter, Context.RECEIVER_NOT_EXPORTED)
+ }
+
+ private fun updateMenuItem(animate: Boolean) {
+ mMenuItem?.let {
+ Menus.setEnabledAndVisible(it, mState != State.INVISIBLE)
+ val icon = it.actionView as ProgressBar
+ when (mState) {
+ State.INDETERMINATE -> icon.isIndeterminate = true
+ State.VISIBLE -> icon.apply {
+ isIndeterminate = false
+ setProgress(mTotalProgress, animate)
+ }
+ State.INVISIBLE -> {}
+ }
+ }
+ }
+
+ /**
+ * Sets the menu item controlled by this class. The item's actionView must be a [ProgressBar].
+ */
+ fun setMenuItem(menuItem: MenuItem) {
+ (menuItem.actionView as ProgressBar).max = MAX_PROGRESS
+ mMenuItem = menuItem
+ updateMenuItem(animate = false)
+ }
+
+ override fun onReceive(context: Context?, intent: Intent) {
+ val progresses = intent.getParcelableArrayListExtra<JobProgress>(
+ EXTRA_PROGRESS,
+ JobProgress::class.java
+ )
+ updateProgress(progresses!!)
+ }
+
+ private fun updateProgress(progresses: List<JobProgress>) {
+ var requiredBytes = 0L
+ var currentBytes = 0L
+ var allFinished = true
+
+ for (jobProgress in progresses) {
+ Log.d(TAG, "Received $jobProgress")
+ mCurrentJobs.put(jobProgress.id, jobProgress)
+ }
+ for (jobProgress in mCurrentJobs.values) {
+ if (jobProgress.state != Job.STATE_COMPLETED) {
+ allFinished = false
+ }
+ if (jobProgress.requiredBytes != -1L && jobProgress.currentBytes != -1L) {
+ requiredBytes += jobProgress.requiredBytes
+ currentBytes += jobProgress.currentBytes
+ }
+ }
+
+ if (mCurrentJobs.isEmpty()) {
+ mState = State.INVISIBLE
+ } else if (requiredBytes != 0L) {
+ mState = State.VISIBLE
+ mTotalProgress = (MAX_PROGRESS * currentBytes / requiredBytes).toInt()
+ } else if (allFinished) {
+ mState = State.VISIBLE
+ mTotalProgress = MAX_PROGRESS
+ } else {
+ mState = State.INDETERMINATE
+ }
+ updateMenuItem(animate = true)
+ }
+}
diff --git a/src/com/android/documentsui/MenuManager.java b/src/com/android/documentsui/MenuManager.java
index 888a45996..a46659c77 100644
--- a/src/com/android/documentsui/MenuManager.java
+++ b/src/com/android/documentsui/MenuManager.java
@@ -282,6 +282,15 @@ public abstract class MenuManager {
public abstract void updateKeyboardShortcutsMenu(
List<KeyboardShortcutGroup> data, IntFunction<String> stringSupplier);
+ /**
+ * Called on option menu creation to instantiate the job progress item if applicable.
+ *
+ * @param menu The option menu created.
+ */
+ public void instantiateJobProgress(Menu menu) {
+ // This icon is not shown in the picker.
+ }
+
protected void updateModePicker(MenuItem grid, MenuItem list) {
// The order of enabling disabling menu item in wrong order removed accessibility focus.
if (mState.derivedMode != State.MODE_LIST) {
diff --git a/src/com/android/documentsui/NavigationViewManager.java b/src/com/android/documentsui/NavigationViewManager.java
index 12afbd69b..bbae22d42 100644
--- a/src/com/android/documentsui/NavigationViewManager.java
+++ b/src/com/android/documentsui/NavigationViewManager.java
@@ -18,6 +18,7 @@ package com.android.documentsui;
import static com.android.documentsui.base.SharedMinimal.VERBOSE;
import static com.android.documentsui.util.FlagUtils.isUseMaterial3FlagEnabled;
+import static com.android.documentsui.util.FlagUtils.isVisualSignalsFlagEnabled;
import android.content.res.Resources;
import android.content.res.TypedArray;
@@ -409,6 +410,9 @@ public class NavigationViewManager extends SelectionTracker.SelectionObserver<St
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);
+ if (isVisualSignalsFlagEnabled()) {
+ mInjector.menuManager.instantiateJobProgress(mToolbar.getMenu());
+ }
}
mInjector.menuManager.updateOptionMenu(mToolbar.getMenu());
mInjector.searchManager.showMenu(mState.stack);
diff --git a/src/com/android/documentsui/ProfileTabs.java b/src/com/android/documentsui/ProfileTabs.java
index 5aacc22b0..74db6f4bd 100644
--- a/src/com/android/documentsui/ProfileTabs.java
+++ b/src/com/android/documentsui/ProfileTabs.java
@@ -157,9 +157,12 @@ public class ProfileTabs implements ProfileTabsAddons {
int tabMarginSide = (int) mTabsContainer.getContext().getResources()
.getDimension(R.dimen.profile_tab_margin_side);
if (isUseMaterial3FlagEnabled()) {
- // M3 uses the margin value as the right margin, except for the last child.
+ final boolean isRtl = mTabs.getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
+ // if use_material3 flag is ON, we uses the margin value as the right margin
+ // (left margin for RTL), except for the last child.
if (i != mTabs.getTabCount() - 1) {
- marginLayoutParams.setMargins(0, 0, tabMarginSide, 0);
+ marginLayoutParams.setMargins(
+ isRtl ? tabMarginSide : 0, 0, isRtl ? 0 : tabMarginSide, 0);
}
} else {
marginLayoutParams.setMargins(tabMarginSide, 0, tabMarginSide, 0);
diff --git a/src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java b/src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java
index 838b1fa72..64409673e 100644
--- a/src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java
+++ b/src/com/android/documentsui/dirlist/DocumentsSwipeRefreshLayout.java
@@ -22,13 +22,13 @@ 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;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.android.documentsui.R;
+import com.android.documentsui.util.ColorUtils;
/**
* A {@link SwipeRefreshLayout} that does not intercept any touch events. This relies on its nested
@@ -46,20 +46,12 @@ public class DocumentsSwipeRefreshLayout extends SwipeRefreshLayout {
super(context, attrs);
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);
+ setColorSchemeColors(
+ ColorUtils.resolveMaterialColorAttribute(
+ context, com.google.android.material.R.attr.colorOnPrimaryContainer));
+ setProgressBackgroundColorSchemeColor(
+ ColorUtils.resolveMaterialColorAttribute(
+ context, com.google.android.material.R.attr.colorPrimaryContainer));
} else {
final int[] styledAttrs = {android.R.attr.colorAccent};
diff --git a/src/com/android/documentsui/dirlist/GridDocumentHolder.java b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
index 9523c331c..703f870e2 100644
--- a/src/com/android/documentsui/dirlist/GridDocumentHolder.java
+++ b/src/com/android/documentsui/dirlist/GridDocumentHolder.java
@@ -41,6 +41,7 @@ import androidx.annotation.RequiresApi;
import com.android.documentsui.ConfigStore;
import com.android.documentsui.DocumentsApplication;
+import com.android.documentsui.IconUtils;
import com.android.documentsui.R;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.Shared;
@@ -94,7 +95,7 @@ final class GridDocumentHolder extends DocumentHolder {
mIconCheck = null;
mThumbnailStrokeWidth =
context.getResources()
- .getDimensionPixelSize(R.dimen.grid_item_thumbnail_border_width);
+ .getDimensionPixelSize(R.dimen.thumbnail_border_width);
} else {
mBullet = null;
mIconWrapper = null;
@@ -112,6 +113,13 @@ final class GridDocumentHolder extends DocumentHolder {
mIconBadge = (ImageView) itemView.findViewById(R.id.icon_profile_badge);
mPreviewIcon = itemView.findViewById(R.id.preview_icon);
+ if (isUseMaterial3FlagEnabled()) {
+ int clipCornerRadius = context.getResources()
+ .getDimensionPixelSize(R.dimen.thumbnail_clip_corner_radius);
+ IconUtils.applyThumbnailClipOutline(
+ mIconThumb, mThumbnailStrokeWidth, clipCornerRadius);
+ }
+
mIconHelper = iconHelper;
if (SdkLevel.isAtLeastT() && !mConfigStore.isPrivateSpaceInDocsUIEnabled()) {
@@ -299,8 +307,8 @@ final class GridDocumentHolder extends DocumentHolder {
}
}
- if (mBullet != null && (mDetails.getVisibility() == View.GONE
- || mDate.getText().isEmpty())) {
+ if (mBullet != null && (mDetails.getText() == null || mDetails.getText().length() == 0
+ || mDate.getText() == null || mDate.getText().length() == 0)) {
// There is no need for the bullet separating the details and date.
mBullet.setVisibility(View.GONE);
}
diff --git a/src/com/android/documentsui/dirlist/ListDocumentHolder.java b/src/com/android/documentsui/dirlist/ListDocumentHolder.java
index 24b7e3ae5..a0db097d0 100644
--- a/src/com/android/documentsui/dirlist/ListDocumentHolder.java
+++ b/src/com/android/documentsui/dirlist/ListDocumentHolder.java
@@ -43,6 +43,7 @@ import androidx.annotation.RequiresApi;
import com.android.documentsui.ConfigStore;
import com.android.documentsui.DocumentsApplication;
+import com.android.documentsui.IconUtils;
import com.android.documentsui.R;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.Lookup;
@@ -108,7 +109,11 @@ final class ListDocumentHolder extends DocumentHolder {
if (isUseMaterial3FlagEnabled()) {
mThumbnailStrokeWidth =
context.getResources()
- .getDimensionPixelSize(R.dimen.list_item_thumbnail_border_width);
+ .getDimensionPixelSize(R.dimen.thumbnail_border_width);
+ int clipCornerRadius = context.getResources()
+ .getDimensionPixelSize(R.dimen.thumbnail_clip_corner_radius);
+ IconUtils.applyThumbnailClipOutline(
+ mIconThumb, mThumbnailStrokeWidth, clipCornerRadius);
} else {
mThumbnailStrokeWidth = 0;
}
diff --git a/src/com/android/documentsui/files/MenuManager.java b/src/com/android/documentsui/files/MenuManager.java
index 7dc6b57d6..2c85dcb01 100644
--- a/src/com/android/documentsui/files/MenuManager.java
+++ b/src/com/android/documentsui/files/MenuManager.java
@@ -17,6 +17,7 @@
package com.android.documentsui.files;
import static com.android.documentsui.util.FlagUtils.isDesktopFileHandlingFlagEnabled;
+import static com.android.documentsui.util.FlagUtils.isVisualSignalsFlagEnabled;
import android.content.Context;
import android.content.res.Resources;
@@ -30,9 +31,11 @@ import android.view.MenuItem;
import android.view.View;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.selection.SelectionTracker;
+import com.android.documentsui.JobPanelController;
import com.android.documentsui.R;
import com.android.documentsui.base.DocumentInfo;
import com.android.documentsui.base.Features;
@@ -56,6 +59,7 @@ public final class MenuManager extends com.android.documentsui.MenuManager {
private final SelectionTracker<String> mSelectionManager;
private final Lookup<String, Uri> mUriLookup;
private final LookupApplicationName mAppNameLookup;
+ @Nullable private final JobPanelController mJobPanelController;
public MenuManager(
Features features,
@@ -75,6 +79,12 @@ public final class MenuManager extends com.android.documentsui.MenuManager {
mSelectionManager = selectionManager;
mAppNameLookup = appNameLookup;
mUriLookup = uriLookup;
+
+ if (isVisualSignalsFlagEnabled()) {
+ mJobPanelController = new JobPanelController(context);
+ } else {
+ mJobPanelController = null;
+ }
}
@Override
@@ -142,6 +152,14 @@ public final class MenuManager extends com.android.documentsui.MenuManager {
}
@Override
+ public void instantiateJobProgress(Menu menu) {
+ if (mJobPanelController == null) {
+ return;
+ }
+ mJobPanelController.setMenuItem(menu.findItem(R.id.option_menu_job_progress));
+ }
+
+ @Override
protected void updateSettings(MenuItem settings, RootInfo root) {
Menus.setEnabledAndVisible(settings, root.hasSettings());
}
diff --git a/src/com/android/documentsui/queries/SearchChipViewManager.java b/src/com/android/documentsui/queries/SearchChipViewManager.java
index c2403dd03..bf3d1e865 100644
--- a/src/com/android/documentsui/queries/SearchChipViewManager.java
+++ b/src/com/android/documentsui/queries/SearchChipViewManager.java
@@ -521,7 +521,7 @@ public class SearchChipViewManager {
}
// Let the first checked chip can be shown.
- View parent = (View) mChipGroup.getParent();
+ View parent = (View) mChipGroup.getParent().getParent();
if (parent instanceof HorizontalScrollView) {
final int scrollToX = isRtl ? parent.getWidth() : 0;
((HorizontalScrollView) parent).smoothScrollTo(scrollToX, 0);
diff --git a/src/com/android/documentsui/services/FileOperationService.java b/src/com/android/documentsui/services/FileOperationService.java
index dcb2c1db4..14b87ef5b 100644
--- a/src/com/android/documentsui/services/FileOperationService.java
+++ b/src/com/android/documentsui/services/FileOperationService.java
@@ -68,6 +68,9 @@ public class FileOperationService extends Service implements Job.Listener {
public static final String EXTRA_OPERATION = "com.android.documentsui.OPERATION";
public static final String EXTRA_CANCEL = "com.android.documentsui.CANCEL";
+ public static final String ACTION_PROGRESS = "com.android.documentsui.action.PROGRESS";
+ public static final String EXTRA_PROGRESS = "com.android.documentsui.PROGRESS";
+
@IntDef({
OPERATION_UNKNOWN,
OPERATION_COPY,
@@ -580,6 +583,7 @@ public class FileOperationService extends Service implements Job.Listener {
private final class GlobalJobMonitor implements Runnable {
private static final long PROGRESS_INTERVAL_MILLIS = 500L;
private boolean mRunning = false;
+ private long mLastId = 0;
private void start() {
if (!mRunning) {
@@ -602,9 +606,9 @@ public class FileOperationService extends Service implements Job.Listener {
}
Intent intent = new Intent();
intent.setPackage(getPackageName());
- intent.setAction("com.android.documentsui.PROGRESS");
- intent.putExtra("id", 0);
- intent.putParcelableArrayListExtra("progress", progress);
+ intent.setAction(ACTION_PROGRESS);
+ intent.putExtra("id", mLastId++);
+ intent.putParcelableArrayListExtra(EXTRA_PROGRESS, progress);
sendBroadcast(intent);
}
diff --git a/src/com/android/documentsui/services/Job.java b/src/com/android/documentsui/services/Job.java
index 0f432cc19..95ad8b335 100644
--- a/src/com/android/documentsui/services/Job.java
+++ b/src/com/android/documentsui/services/Job.java
@@ -76,11 +76,11 @@ abstract public class Job implements Runnable {
@Retention(RetentionPolicy.SOURCE)
@IntDef({STATE_CREATED, STATE_STARTED, STATE_SET_UP, STATE_COMPLETED, STATE_CANCELED})
- @interface State {}
- static final int STATE_CREATED = 0;
- static final int STATE_STARTED = 1;
- static final int STATE_SET_UP = 2;
- static final int STATE_COMPLETED = 3;
+ public @interface State {}
+ public static final int STATE_CREATED = 0;
+ public static final int STATE_STARTED = 1;
+ public static final int STATE_SET_UP = 2;
+ public static final int STATE_COMPLETED = 3;
/**
* A job is in canceled state as long as {@link #cancel()} is called on it, even after it is
* completed.
diff --git a/src/com/android/documentsui/util/ColorUtils.kt b/src/com/android/documentsui/util/ColorUtils.kt
new file mode 100644
index 000000000..ee67b7832
--- /dev/null
+++ b/src/com/android/documentsui/util/ColorUtils.kt
@@ -0,0 +1,36 @@
+/*
+ * 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 android.content.Context
+import android.util.TypedValue
+import androidx.annotation.AttrRes
+
+class ColorUtils {
+ companion object {
+ /**
+ * Resolve a color attribute from the Material3 theme, example usage.
+ * resolveMaterialColorAttribute(context, com.google.android.material.R.attr.XXX).
+ */
+ @JvmStatic
+ fun resolveMaterialColorAttribute(context: Context, @AttrRes colorAttrId: Int): Int {
+ val typedValue = TypedValue()
+ context.theme.resolveAttribute(colorAttrId, typedValue, true)
+ return typedValue.data
+ }
+ }
+}
diff --git a/tests/functional/com/android/documentsui/TrampolineActivityTest.kt b/tests/functional/com/android/documentsui/TrampolineActivityTest.kt
index 6bf0975ad..10b31d1eb 100644
--- a/tests/functional/com/android/documentsui/TrampolineActivityTest.kt
+++ b/tests/functional/com/android/documentsui/TrampolineActivityTest.kt
@@ -15,10 +15,8 @@
*/
package com.android.documentsui
-import android.app.Instrumentation
import android.content.Intent
import android.content.Intent.ACTION_GET_CONTENT
-import android.content.IntentFilter
import android.os.Build.VERSION_CODES
import android.platform.test.annotations.RequiresFlagsEnabled
import android.platform.test.flag.junit.CheckFlagsRule
@@ -34,7 +32,6 @@ import com.android.documentsui.flags.Flags.FLAG_REDIRECT_GET_CONTENT_RO
import com.android.documentsui.picker.TrampolineActivity
import java.util.Optional
import java.util.regex.Pattern
-import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.BeforeClass
@@ -56,25 +53,28 @@ class TrampolineActivityTest() {
const val UI_TIMEOUT = 5000L
val PHOTOPICKER_PACKAGE_REGEX: Pattern = Pattern.compile(".*(photopicker|media\\.module).*")
val DOCUMENTSUI_PACKAGE_REGEX: Pattern = Pattern.compile(".*documentsui.*")
+ val STACK_LIST_REGEX: Pattern = Pattern.compile(
+ "taskId=(?<taskId>[0-9]+):(.+?)(photopicker|media\\.module|documentsui)",
+ Pattern.MULTILINE
+ )
private lateinit var device: UiDevice
- private lateinit var monitor: Instrumentation.ActivityMonitor
+ fun removePhotopickerAndDocumentsUITasks() {
+ // Get the current list of tasks that are visible.
+ val result = device.executeShellCommand("am stack list")
+
+ // Identify any that are from DocumentsUI or Photopicker and close them.
+ val matcher = STACK_LIST_REGEX.matcher(result)
+ while (matcher.find()) {
+ device.executeShellCommand("am stack remove ${matcher.group("taskId")}")
+ }
+ }
@BeforeClass
@JvmStatic
fun setUp() {
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
-
- // Monitor to wait for the activity that starts with the `ACTION_GET_CONTENT` intent.
- val intentFilter = IntentFilter().apply { addAction(ACTION_GET_CONTENT) }
- monitor =
- Instrumentation.ActivityMonitor(
- intentFilter,
- null, // Expected result from startActivityForResult.
- true, // Whether to block until activity started or not.
- )
- InstrumentationRegistry.getInstrumentation().addMonitor(monitor)
}
}
@@ -157,24 +157,20 @@ class TrampolineActivityTest() {
@Before
fun setUp() {
+ removePhotopickerAndDocumentsUITasks()
+
val context = InstrumentationRegistry.getInstrumentation().targetContext
val intent = Intent(ACTION_GET_CONTENT)
intent.setClass(context, TrampolineActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.setType(testData.mimeType)
if (testData.extraMimeTypes.isPresent) {
- testData.extraMimeTypes.get()
- .forEach { intent.putExtra(Intent.EXTRA_MIME_TYPES, it) }
+ intent.putExtra(Intent.EXTRA_MIME_TYPES, testData.extraMimeTypes.get())
}
context.startActivity(intent)
}
- @After
- fun tearDown() {
- monitor.waitForActivityWithTimeout(UI_TIMEOUT)?.finish()
- }
-
@Test
fun testCorrectAppIsLaunched() {
val bySelector = when (testData.expectedApp) {
@@ -208,6 +204,11 @@ class TrampolineActivityTest() {
@get:Rule
val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule()
+ @Before
+ fun setUp() {
+ removePhotopickerAndDocumentsUITasks()
+ }
+
@Test
fun testReferredGetContentFromPhotopickerShouldNotRedirectBack() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
diff --git a/tests/unit/com/android/documentsui/JobPanelControllerTest.kt b/tests/unit/com/android/documentsui/JobPanelControllerTest.kt
new file mode 100644
index 000000000..be0c9adbd
--- /dev/null
+++ b/tests/unit/com/android/documentsui/JobPanelControllerTest.kt
@@ -0,0 +1,207 @@
+/*
+ * 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
+
+import android.content.Intent
+import android.platform.test.annotations.RequiresFlagsEnabled
+import android.platform.test.flag.junit.CheckFlagsRule
+import android.platform.test.flag.junit.DeviceFlagsValueProvider
+import android.widget.ActionMenuView
+import android.widget.ProgressBar
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.documentsui.flags.Flags.FLAG_USE_MATERIAL3
+import com.android.documentsui.flags.Flags.FLAG_VISUAL_SIGNALS_RO
+import com.android.documentsui.services.FileOperationService.ACTION_PROGRESS
+import com.android.documentsui.services.FileOperationService.EXTRA_PROGRESS
+import com.android.documentsui.services.Job
+import com.android.documentsui.services.JobProgress
+import junit.framework.Assert.assertEquals
+import junit.framework.Assert.assertFalse
+import junit.framework.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private data class MutableJobProgress(
+ var id: String,
+ @Job.State var state: Int,
+ var msg: String?,
+ var hasFailures: Boolean,
+ var currentBytes: Long = -1,
+ var requiredBytes: Long = -1,
+ var msRemaining: Long = -1,
+) {
+ fun toJobProgress() =
+ JobProgress(id, state, msg, hasFailures, currentBytes, requiredBytes, msRemaining)
+}
+
+@SmallTest
+@RequiresFlagsEnabled(FLAG_USE_MATERIAL3, FLAG_VISUAL_SIGNALS_RO)
+@RunWith(AndroidJUnit4::class)
+class JobPanelControllerTest {
+ @get:Rule
+ val mCheckFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule()
+
+ private val mContext = InstrumentationRegistry.getInstrumentation().targetContext
+
+ // The default progress bar only has an indeterminate state, so we need to style it to allow
+ // determinate progress.
+ private val mProgressBar = ProgressBar(
+ mContext,
+ null,
+ android.R.attr.progressBarStyleHorizontal
+ )
+ private val mMenuItem = ActionMenuView(mContext).menu.add("job_panel").apply {
+ actionView = mProgressBar
+ }
+ private lateinit var mController: JobPanelController
+ private var mLastId = 0L
+
+ private fun sendProgress(progress: ArrayList<JobProgress>, id: Long = mLastId++) {
+ var intent = Intent(ACTION_PROGRESS).apply {
+ `package` = mContext.packageName
+ putExtra("id", id)
+ putParcelableArrayListExtra(EXTRA_PROGRESS, progress)
+ }
+ mController.onReceive(mContext, intent)
+ }
+
+ @Before
+ fun setUp() {
+ mController = JobPanelController(mContext)
+ mController.setMenuItem(mMenuItem)
+ }
+
+ @Test
+ fun testSingleJob() {
+ assertFalse(mMenuItem.isVisible())
+ assertFalse(mMenuItem.isEnabled())
+
+ val progress = MutableJobProgress(
+ id = "jobId1",
+ state = Job.STATE_STARTED,
+ msg = "Job started",
+ hasFailures = false,
+ currentBytes = 0,
+ requiredBytes = 10,
+ msRemaining = -1
+ )
+ sendProgress(arrayListOf(progress.toJobProgress()))
+
+ assertTrue(mMenuItem.isVisible())
+ assertTrue(mMenuItem.isEnabled())
+ assertEquals(0, mProgressBar.progress)
+
+ progress.apply {
+ state = Job.STATE_SET_UP
+ msg = "Job in progress"
+ currentBytes = 4
+ }
+ sendProgress(arrayListOf(progress.toJobProgress()))
+
+ assertTrue(mMenuItem.isVisible())
+ assertTrue(mMenuItem.isEnabled())
+ assertEquals(40, mProgressBar.progress)
+
+ progress.apply {
+ state = Job.STATE_COMPLETED
+ msg = "Job completed"
+ currentBytes = 10
+ }
+ sendProgress(arrayListOf(progress.toJobProgress()))
+
+ assertTrue(mMenuItem.isVisible())
+ assertTrue(mMenuItem.isEnabled())
+ assertEquals(100, mProgressBar.progress)
+ }
+
+ @Test
+ fun testMultipleJobs() {
+ assertFalse(mMenuItem.isVisible())
+ assertFalse(mMenuItem.isEnabled())
+
+ val progress1 = MutableJobProgress(
+ id = "jobId1",
+ state = Job.STATE_STARTED,
+ msg = "Job started",
+ hasFailures = false,
+ currentBytes = 0,
+ requiredBytes = 10,
+ msRemaining = -1
+ )
+ val progress2 = MutableJobProgress(
+ id = "jobId2",
+ state = Job.STATE_STARTED,
+ msg = "Job started",
+ hasFailures = false,
+ currentBytes = 0,
+ requiredBytes = 40,
+ msRemaining = -1
+ )
+ sendProgress(arrayListOf(progress1.toJobProgress(), progress2.toJobProgress()))
+
+ assertTrue(mMenuItem.isVisible())
+ assertTrue(mMenuItem.isEnabled())
+ assertEquals(0, mProgressBar.progress)
+
+ progress1.apply {
+ state = Job.STATE_SET_UP
+ msg = "Job in progress"
+ currentBytes = 4
+ }
+ sendProgress(arrayListOf(progress1.toJobProgress(), progress2.toJobProgress()))
+
+ assertTrue(mMenuItem.isVisible())
+ assertTrue(mMenuItem.isEnabled())
+ assertEquals(8, mProgressBar.progress)
+
+ progress1.apply {
+ state = Job.STATE_COMPLETED
+ msg = "Job completed"
+ currentBytes = 10
+ }
+ sendProgress(arrayListOf(progress1.toJobProgress(), progress2.toJobProgress()))
+
+ assertTrue(mMenuItem.isVisible())
+ assertTrue(mMenuItem.isEnabled())
+ assertEquals(20, mProgressBar.progress)
+
+ progress2.apply {
+ state = Job.STATE_SET_UP
+ msg = "Job in progress"
+ currentBytes = 30
+ }
+ sendProgress(arrayListOf(progress1.toJobProgress(), progress2.toJobProgress()))
+
+ assertTrue(mMenuItem.isVisible())
+ assertTrue(mMenuItem.isEnabled())
+ assertEquals(80, mProgressBar.progress)
+
+ progress2.apply {
+ state = Job.STATE_COMPLETED
+ msg = "Job completed"
+ currentBytes = 40
+ }
+ sendProgress(arrayListOf(progress1.toJobProgress(), progress2.toJobProgress()))
+
+ assertTrue(mMenuItem.isVisible())
+ assertTrue(mMenuItem.isEnabled())
+ assertEquals(100, mProgressBar.progress)
+ }
+}
diff --git a/tests/unit/com/android/documentsui/queries/SearchChipViewManagerTest.java b/tests/unit/com/android/documentsui/queries/SearchChipViewManagerTest.java
index 6d20447dd..02e24a329 100644
--- a/tests/unit/com/android/documentsui/queries/SearchChipViewManagerTest.java
+++ b/tests/unit/com/android/documentsui/queries/SearchChipViewManagerTest.java
@@ -18,6 +18,7 @@ package com.android.documentsui.queries;
import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.spy;
@@ -35,6 +36,9 @@ import android.platform.test.flag.junit.DeviceFlagsValueProvider;
import android.provider.DocumentsContract;
import android.view.View;
import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.FrameLayout;
+import android.widget.HorizontalScrollView;
import android.widget.LinearLayout;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -235,6 +239,37 @@ public final class SearchChipViewManagerTest {
assertThat(View.VISIBLE).isEqualTo(mirror.getVisibility());
}
+ @Test
+ public void testChipChecked_resetScroll() {
+ // Mock chip group's parent chain according to search_chip_row.xml.
+ FrameLayout parent = spy(new FrameLayout(mContext));
+ HorizontalScrollView grandparent = spy(new HorizontalScrollView(mContext));
+ parent.addView(mChipGroup);
+ grandparent.addView(parent);
+ // Verify that getParent().getParent() returns the HorizontalScrollView mock.
+ ViewParent result = mChipGroup.getParent().getParent();
+ assertEquals(grandparent, result);
+
+ mSearchChipViewManager.initChipSets(
+ new String[] {"image/*", "audio/*", "video/*", "text/*"});
+ mSearchChipViewManager.updateChips(new String[] {"*/*"});
+
+ // Manually set HorizontalScrollView's scrollX to something larger than 0.
+ grandparent.scrollTo(100, 0);
+ assertTrue(grandparent.getScaleX() > 0);
+
+ assertEquals(6, mChipGroup.getChildCount());
+ Chip lastChip = (Chip) mChipGroup.getChildAt(5);
+
+ // chip.setChecked will trigger reorder animation, which needs to be run inside
+ // the looper thread.
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ // Check last chip will move it to the first child and reset scroll view.
+ lastChip.setChecked(true);
+ assertEquals(0, grandparent.getScrollX());
+ });
+ }
+
private static Set<SearchChipData> getFakeSearchChipDataList() {
final Set<SearchChipData> chipDataList = new HashSet<>();
chipDataList.add(new SearchChipData(CHIP_TYPE, 0 /* titleRes */, TEST_MIME_TYPES));