diff options
author | 2025-03-03 18:01:34 -0800 | |
---|---|---|
committer | 2025-03-06 11:02:37 -0800 | |
commit | f4aa685b65bc8ba11239056784ad93a01b0dc0d9 (patch) | |
tree | 4ab4d60b144170db6e7721993cf0b20688df33df | |
parent | 41a2018978cd0c160ffe673e475e104b49dcde6d (diff) |
Fix a11y focus issue with partial scrolls and notifyDataSetChanged.
When using accessibility to navigate to an off-screen item, RecyclerView
would attempt to scroll the entire view to bring the next item to the
top. However, if there were insufficient items for a full scroll, and
the scroll triggered a notifyDataSetChanged call, RecyclerView would
incorrectly reset focus to the first visible item after the scroll.
Empirically, the notifyDataSetChanged is triggred by the icon loading.
The workaround for the issue is to make ChooserGridAdapter to invoke
notifyItemChange when the corresponded icon gets loaded.
Fix: 298193161
Test: manual testing
Flag: android.service.chooser.notify_single_item_change_on_icon_load
Change-Id: I6def9705e57122ae69c36d87f92cdb40b5439651
4 files changed, 111 insertions, 1 deletions
diff --git a/core/java/android/service/chooser/flags.aconfig b/core/java/android/service/chooser/flags.aconfig index ae0b56e6f009..45a21beabd89 100644 --- a/core/java/android/service/chooser/flags.aconfig +++ b/core/java/android/service/chooser/flags.aconfig @@ -44,6 +44,16 @@ flag { } flag { + name: "notify_single_item_change_on_icon_load" + namespace: "intentresolver" + description: "ChooserGridAdapter to notify specific items change when the target icon is loaded (instead of all-item change)." + bug: "298193161" + metadata { + purpose: PURPOSE_BUGFIX + } +} + +flag { name: "fix_resolver_memory_leak" is_exported: true namespace: "intentresolver" diff --git a/core/java/com/android/internal/app/ChooserActivity.java b/core/java/com/android/internal/app/ChooserActivity.java index c009fc3b7e63..9bc6671bbc31 100644 --- a/core/java/com/android/internal/app/ChooserActivity.java +++ b/core/java/com/android/internal/app/ChooserActivity.java @@ -23,6 +23,7 @@ import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CANT import static android.app.admin.DevicePolicyResources.Strings.Core.RESOLVER_CROSS_PROFILE_BLOCKED_TITLE; import static android.content.ContentProvider.getUriWithoutUserId; import static android.content.ContentProvider.getUserIdFromUri; +import static android.service.chooser.Flags.notifySingleItemChangeOnIconLoad; import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_PERSONAL; import static android.stats.devicepolicy.DevicePolicyEnums.RESOLVER_EMPTY_STATE_NO_SHARING_TO_WORK; @@ -163,9 +164,11 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -3212,6 +3215,8 @@ public class ChooserActivity extends ResolverActivity implements private static final int NUM_EXPANSIONS_TO_HIDE_AZ_LABEL = 20; + private final Set<ViewHolderBase> mBoundViewHolders = new HashSet<>(); + ChooserGridAdapter(ChooserListAdapter wrappedAdapter) { super(); mChooserListAdapter = wrappedAdapter; @@ -3232,6 +3237,31 @@ public class ChooserActivity extends ResolverActivity implements notifyDataSetChanged(); } }); + if (notifySingleItemChangeOnIconLoad()) { + wrappedAdapter.setOnIconLoadedListener(this::onTargetIconLoaded); + } + } + + private void onTargetIconLoaded(DisplayResolveInfo info) { + for (ViewHolderBase holder : mBoundViewHolders) { + switch (holder.getViewType()) { + case VIEW_TYPE_NORMAL: + TargetInfo itemInfo = + mChooserListAdapter.getItem( + ((ItemViewHolder) holder).mListPosition); + if (info == itemInfo) { + notifyItemChanged(holder.getAdapterPosition()); + } + break; + case VIEW_TYPE_CALLER_AND_RANK: + ItemGroupViewHolder groupHolder = (ItemGroupViewHolder) holder; + if (suggestedAppsGroupContainsTarget(groupHolder, info)) { + notifyItemChanged(holder.getAdapterPosition()); + } + break; + } + + } } public void setFooterHeight(int height) { @@ -3382,6 +3412,9 @@ public class ChooserActivity extends ResolverActivity implements @Override public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + if (notifySingleItemChangeOnIconLoad()) { + mBoundViewHolders.add((ViewHolderBase) holder); + } int viewType = ((ViewHolderBase) holder).getViewType(); switch (viewType) { case VIEW_TYPE_DIRECT_SHARE: @@ -3396,6 +3429,22 @@ public class ChooserActivity extends ResolverActivity implements } @Override + public void onViewRecycled(RecyclerView.ViewHolder holder) { + if (notifySingleItemChangeOnIconLoad()) { + mBoundViewHolders.remove((ViewHolderBase) holder); + } + super.onViewRecycled(holder); + } + + @Override + public boolean onFailedToRecycleView(RecyclerView.ViewHolder holder) { + if (notifySingleItemChangeOnIconLoad()) { + mBoundViewHolders.remove((ViewHolderBase) holder); + } + return super.onFailedToRecycleView(holder); + } + + @Override public int getItemViewType(int position) { int count; @@ -3604,6 +3653,33 @@ public class ChooserActivity extends ResolverActivity implements } } + /** + * Checks whether the suggested apps group, {@code holder}, contains the target, + * {@code info}. + */ + private boolean suggestedAppsGroupContainsTarget( + ItemGroupViewHolder holder, DisplayResolveInfo info) { + + int position = holder.getAdapterPosition(); + int start = getListPosition(position); + int startType = getRowType(start); + + int columnCount = holder.getColumnCount(); + int end = start + columnCount - 1; + while (getRowType(end) != startType && end >= start) { + end--; + } + + for (int i = 0; i < columnCount; i++) { + if (start + i <= end) { + if (mChooserListAdapter.getItem(holder.getItemIndex(i)) == info) { + return true; + } + } + } + return false; + } + int getListPosition(int position) { position -= getSystemRowCount() + getProfileRowCount(); diff --git a/core/java/com/android/internal/app/ChooserListAdapter.java b/core/java/com/android/internal/app/ChooserListAdapter.java index d38689c7505b..1b8c36db3908 100644 --- a/core/java/com/android/internal/app/ChooserListAdapter.java +++ b/core/java/com/android/internal/app/ChooserListAdapter.java @@ -16,9 +16,12 @@ package com.android.internal.app; +import static android.service.chooser.Flags.notifySingleItemChangeOnIconLoad; + import static com.android.internal.app.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; import static com.android.internal.app.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_SHORTCUT_MANAGER; +import android.annotation.Nullable; import android.app.prediction.AppPredictor; import android.content.ComponentName; import android.content.Context; @@ -56,6 +59,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Consumer; public class ChooserListAdapter extends ResolverListAdapter { private static final String TAG = "ChooserListAdapter"; @@ -108,6 +112,9 @@ public class ChooserListAdapter extends ResolverListAdapter { // Represents the UserSpace in which the Initial Intents should be resolved. private final UserHandle mInitialIntentsUserSpace; + @Nullable + private Consumer<DisplayResolveInfo> mOnIconLoadedListener; + // For pinned direct share labels, if the text spans multiple lines, the TextView will consume // the full width, even if the characters actually take up less than that. Measure the actual // line widths and constrain the View's width based upon that so that the pin doesn't end up @@ -218,6 +225,10 @@ public class ChooserListAdapter extends ResolverListAdapter { true); } + public void setOnIconLoadedListener(Consumer<DisplayResolveInfo> onIconLoadedListener) { + mOnIconLoadedListener = onIconLoadedListener; + } + AppPredictor getAppPredictor() { return mAppPredictor; } @@ -329,6 +340,15 @@ public class ChooserListAdapter extends ResolverListAdapter { } } + @Override + protected void onIconLoaded(DisplayResolveInfo info) { + if (notifySingleItemChangeOnIconLoad() && mOnIconLoadedListener != null) { + mOnIconLoadedListener.accept(info); + } else { + notifyDataSetChanged(); + } + } + private void loadDirectShareIcon(SelectableTargetInfo info) { LoadDirectShareIconTask task = (LoadDirectShareIconTask) mIconLoaders.get(info); if (task == null) { diff --git a/core/java/com/android/internal/app/ResolverListAdapter.java b/core/java/com/android/internal/app/ResolverListAdapter.java index 54c0e61fd5cd..4d9ce86096c7 100644 --- a/core/java/com/android/internal/app/ResolverListAdapter.java +++ b/core/java/com/android/internal/app/ResolverListAdapter.java @@ -680,6 +680,10 @@ public class ResolverListAdapter extends BaseAdapter { } } + protected void onIconLoaded(DisplayResolveInfo info) { + notifyDataSetChanged(); + } + private void loadLabel(DisplayResolveInfo info) { LoadLabelTask task = mLabelLoaders.get(info); if (task == null) { @@ -1004,7 +1008,7 @@ public class ResolverListAdapter extends BaseAdapter { mResolverListCommunicator.updateProfileViewButton(); } else if (!mDisplayResolveInfo.hasDisplayIcon()) { mDisplayResolveInfo.setDisplayIcon(d); - notifyDataSetChanged(); + onIconLoaded(mDisplayResolveInfo); } } } |