diff options
24 files changed, 610 insertions, 63 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 518643ce0..cf57e1dce 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 @@ -60,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> @@ -222,6 +227,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/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 72ba1c3f9..67ee1f2bf 100644 --- a/src/com/android/documentsui/dirlist/GridDocumentHolder.java +++ b/src/com/android/documentsui/dirlist/GridDocumentHolder.java @@ -307,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/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/FilesActivityUiTest.java b/tests/functional/com/android/documentsui/FilesActivityUiTest.java index f1f505235..6c2397068 100644 --- a/tests/functional/com/android/documentsui/FilesActivityUiTest.java +++ b/tests/functional/com/android/documentsui/FilesActivityUiTest.java @@ -17,11 +17,14 @@ package com.android.documentsui; import static com.android.documentsui.flags.Flags.FLAG_HIDE_ROOTS_ON_DESKTOP_RO; +import static com.android.documentsui.flags.Flags.FLAG_USE_SEARCH_V2_READ_ONLY; +import static com.android.documentsui.flags.Flags.FLAG_USE_MATERIAL3; import android.app.Instrumentation; import android.net.Uri; import android.os.RemoteException; import android.platform.test.annotations.RequiresFlagsDisabled; +import android.platform.test.annotations.RequiresFlagsEnabled; import android.platform.test.flag.junit.CheckFlagsRule; import android.platform.test.flag.junit.DeviceFlagsValueProvider; @@ -92,13 +95,23 @@ public class FilesActivityUiTest extends ActivityTestJunit4<FilesActivity> { bots.main.assertWindowTitle("Images"); } + private void filesListed() throws Exception { + bots.directory.assertDocumentsPresent("file0.log", "file1.png", "file2.csv"); + } + @Test + @RequiresFlagsDisabled(FLAG_USE_SEARCH_V2_READ_ONLY) public void testFilesListed() throws Exception { - bots.directory.assertDocumentsPresent("file0.log", "file1.png", "file2.csv"); + filesListed(); } @Test - public void testFilesList_LiveUpdate() throws Exception { + @RequiresFlagsEnabled({FLAG_USE_SEARCH_V2_READ_ONLY, FLAG_USE_MATERIAL3}) + public void testFilesListed_searchV2() throws Exception { + filesListed(); + } + + private void filesListed_LiveUpdates() throws Exception { mDocsHelper.createDocument(rootDir0, "yummers/sandwich", "Ham & Cheese.sandwich"); bots.directory.waitForDocument("Ham & Cheese.sandwich"); @@ -107,6 +120,18 @@ public class FilesActivityUiTest extends ActivityTestJunit4<FilesActivity> { } @Test + @RequiresFlagsDisabled(FLAG_USE_SEARCH_V2_READ_ONLY) + public void testFilesList_LiveUpdate() throws Exception { + filesListed_LiveUpdates(); + } + + @Test + @RequiresFlagsEnabled({FLAG_USE_SEARCH_V2_READ_ONLY, FLAG_USE_MATERIAL3}) + public void testFilesList_LiveUpdate_searchV2() throws Exception { + filesListed_LiveUpdates(); + } + + @Test public void testNavigate_byBreadcrumb() throws Exception { bots.directory.openDocument(dirName1); bots.directory.waitForDocument(childDir1); // wait for known content 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)); |