1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.launcher3.widget.picker;
17 
18 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_WIDGETSTRAY_APP_EXPANDED;
19 import static com.android.launcher3.recyclerview.ViewHolderBinder.POSITION_DEFAULT;
20 import static com.android.launcher3.recyclerview.ViewHolderBinder.POSITION_FIRST;
21 import static com.android.launcher3.recyclerview.ViewHolderBinder.POSITION_LAST;
22 
23 import android.content.Context;
24 import android.graphics.Rect;
25 import android.os.Process;
26 import android.util.Log;
27 import android.util.SparseArray;
28 import android.view.LayoutInflater;
29 import android.view.View;
30 import android.view.View.OnClickListener;
31 import android.view.View.OnLongClickListener;
32 import android.view.ViewGroup;
33 
34 import androidx.annotation.NonNull;
35 import androidx.annotation.Nullable;
36 import androidx.recyclerview.widget.LinearLayoutManager;
37 import androidx.recyclerview.widget.RecyclerView;
38 import androidx.recyclerview.widget.RecyclerView.Adapter;
39 import androidx.recyclerview.widget.RecyclerView.LayoutParams;
40 import androidx.recyclerview.widget.RecyclerView.ViewHolder;
41 
42 import com.android.launcher3.R;
43 import com.android.launcher3.icons.IconCache;
44 import com.android.launcher3.model.data.PackageItemInfo;
45 import com.android.launcher3.recyclerview.ViewHolderBinder;
46 import com.android.launcher3.util.LabelComparator;
47 import com.android.launcher3.util.PackageUserKey;
48 import com.android.launcher3.views.ActivityContext;
49 import com.android.launcher3.widget.model.WidgetListSpaceEntry;
50 import com.android.launcher3.widget.model.WidgetsListBaseEntry;
51 import com.android.launcher3.widget.model.WidgetsListContentEntry;
52 import com.android.launcher3.widget.model.WidgetsListHeaderEntry;
53 import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry;
54 
55 import java.util.ArrayList;
56 import java.util.Arrays;
57 import java.util.Collections;
58 import java.util.Comparator;
59 import java.util.List;
60 import java.util.Map;
61 import java.util.OptionalInt;
62 import java.util.function.IntSupplier;
63 import java.util.function.Predicate;
64 import java.util.stream.Collectors;
65 import java.util.stream.IntStream;
66 
67 /**
68  * Recycler view adapter for the widget tray.
69  *
70  * <p>This adapter supports view binding of subclasses of {@link WidgetsListBaseEntry}. There are 2
71  * subclasses: {@link WidgetsListHeader} & {@link WidgetsListContentEntry}.
72  * {@link WidgetsListHeader} entries are always visible in the recycler view. At most one
73  * {@link WidgetsListContentEntry} is shown in the recycler view at any time. Clicking a
74  * {@link WidgetsListHeader} will result in expanding / collapsing a corresponding
75  * {@link WidgetsListContentEntry} of the same app.
76  */
77 public class WidgetsListAdapter extends Adapter<ViewHolder> implements OnHeaderClickListener {
78 
79     private static final String TAG = "WidgetsListAdapter";
80     private static final boolean DEBUG = false;
81 
82     /** Uniquely identifies widgets list view type within the app. */
83     private static final int VIEW_TYPE_WIDGETS_SPACE = R.id.view_type_widgets_space;
84     private static final int VIEW_TYPE_WIDGETS_LIST = R.id.view_type_widgets_list;
85     private static final int VIEW_TYPE_WIDGETS_HEADER = R.id.view_type_widgets_header;
86     private static final int VIEW_TYPE_WIDGETS_SEARCH_HEADER = R.id.view_type_widgets_search_header;
87 
88     private final Context mContext;
89     private final WidgetsDiffReporter mDiffReporter;
90     private final SparseArray<ViewHolderBinder> mViewHolderBinders = new SparseArray<>();
91     private final WidgetListBaseRowEntryComparator mRowComparator =
92             new WidgetListBaseRowEntryComparator();
93 
94     private final List<WidgetsListBaseEntry> mAllEntries = new ArrayList<>();
95     private ArrayList<WidgetsListBaseEntry> mVisibleEntries = new ArrayList<>();
96     @Nullable private PackageUserKey mWidgetsContentVisiblePackageUserKey = null;
97 
98     private Predicate<WidgetsListBaseEntry> mHeaderAndSelectedContentFilter = entry ->
99             entry instanceof WidgetsListHeaderEntry
100                     || entry instanceof WidgetsListSearchHeaderEntry
101                     || PackageUserKey.fromPackageItemInfo(entry.mPkgItem)
102                             .equals(mWidgetsContentVisiblePackageUserKey);
103     @Nullable private Predicate<WidgetsListBaseEntry> mFilter = null;
104     @Nullable private RecyclerView mRecyclerView;
105     @Nullable private PackageUserKey mPendingClickHeader;
106     private final int mSpacingBetweenEntries;
107     private int mMaxSpanSize = 4;
108 
WidgetsListAdapter(Context context, LayoutInflater layoutInflater, IconCache iconCache, IntSupplier emptySpaceHeightProvider, OnClickListener iconClickListener, OnLongClickListener iconLongClickListener)109     public WidgetsListAdapter(Context context, LayoutInflater layoutInflater,
110             IconCache iconCache, IntSupplier emptySpaceHeightProvider,
111             OnClickListener iconClickListener, OnLongClickListener iconLongClickListener) {
112         mContext = context;
113         mDiffReporter = new WidgetsDiffReporter(iconCache, this);
114         WidgetsListDrawableFactory listDrawableFactory = new WidgetsListDrawableFactory(context);
115 
116         mViewHolderBinders.put(
117                 VIEW_TYPE_WIDGETS_LIST,
118                 new WidgetsListTableViewHolderBinder(
119                         layoutInflater, iconClickListener, iconLongClickListener,
120                         listDrawableFactory));
121         mViewHolderBinders.put(
122                 VIEW_TYPE_WIDGETS_HEADER,
123                 new WidgetsListHeaderViewHolderBinder(
124                         layoutInflater,
125                         /* onHeaderClickListener= */ this,
126                         listDrawableFactory));
127         mViewHolderBinders.put(
128                 VIEW_TYPE_WIDGETS_SEARCH_HEADER,
129                 new WidgetsListSearchHeaderViewHolderBinder(
130                         layoutInflater,
131                         /* onHeaderClickListener= */ this,
132                         listDrawableFactory));
133         mViewHolderBinders.put(
134                 VIEW_TYPE_WIDGETS_SPACE,
135                 new WidgetsSpaceViewHolderBinder(emptySpaceHeightProvider));
136         mSpacingBetweenEntries =
137                 context.getResources().getDimensionPixelSize(R.dimen.widget_list_entry_spacing);
138     }
139 
140     @Override
onAttachedToRecyclerView(@onNull RecyclerView recyclerView)141     public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
142         mRecyclerView = recyclerView;
143 
144         mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
145             @Override
146             public void getItemOffsets(
147                     @NonNull Rect outRect,
148                     @NonNull View view,
149                     @NonNull RecyclerView parent,
150                     @NonNull RecyclerView.State state) {
151                 super.getItemOffsets(outRect, view, parent, state);
152                 int position = ((LayoutParams) view.getLayoutParams()).getViewLayoutPosition();
153                 boolean isHeader =
154                         view.getTag(R.id.tag_widget_entry) instanceof WidgetsListBaseEntry.Header;
155                 outRect.top += position > 0 && isHeader ? mSpacingBetweenEntries : 0;
156             }
157         });
158     }
159 
160     @Override
onDetachedFromRecyclerView(@onNull RecyclerView recyclerView)161     public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
162         mRecyclerView = null;
163     }
164 
setFilter(Predicate<WidgetsListBaseEntry> filter)165     public void setFilter(Predicate<WidgetsListBaseEntry> filter) {
166         mFilter = filter;
167     }
168 
169     @Override
getItemCount()170     public int getItemCount() {
171         return mVisibleEntries.size();
172     }
173 
174     /**
175      * Returns true if the adapter has entries which will be visible to the user
176      */
hasVisibleEntries()177     public boolean hasVisibleEntries() {
178         // Account for the 1st space entry
179         return getItemCount() > 1;
180     }
181 
182     /** Returns all items that will be drawn in a recycler view. */
getItems()183     public List<WidgetsListBaseEntry> getItems() {
184         return mVisibleEntries;
185     }
186 
187     /** Gets the section name for {@link com.android.launcher3.views.RecyclerViewFastScroller}. */
getSectionName(int pos)188     public String getSectionName(int pos) {
189         return mVisibleEntries.get(pos).mTitleSectionName;
190     }
191 
192     /** Updates the widget list based on {@code tempEntries}. */
setWidgets(List<WidgetsListBaseEntry> tempEntries)193     public void setWidgets(List<WidgetsListBaseEntry> tempEntries) {
194         mAllEntries.clear();
195         mAllEntries.add(new WidgetListSpaceEntry());
196         tempEntries.stream().sorted(mRowComparator).forEach(mAllEntries::add);
197         if (shouldClearVisibleEntries()) {
198             mVisibleEntries.clear();
199         }
200         updateVisibleEntries();
201     }
202 
203     /** Updates the widget list based on {@code searchResults}. */
setWidgetsOnSearch(List<WidgetsListBaseEntry> searchResults)204     public void setWidgetsOnSearch(List<WidgetsListBaseEntry> searchResults) {
205         // Forget the expanded package every time widget list is refreshed in search mode.
206         mWidgetsContentVisiblePackageUserKey = null;
207         setWidgets(searchResults);
208     }
209 
updateVisibleEntries()210     private void updateVisibleEntries() {
211         // Get the current top of the header with the matching key before adjusting the visible
212         // entries.
213         OptionalInt previousPositionForPackageUserKey =
214                 getPositionForPackageUserKey(mPendingClickHeader);
215         OptionalInt topForPackageUserKey =
216                 getOffsetForPosition(previousPositionForPackageUserKey);
217 
218         List<WidgetsListBaseEntry> newVisibleEntries = mAllEntries.stream()
219                 .filter(entry -> ((mFilter == null || mFilter.test(entry))
220                         && mHeaderAndSelectedContentFilter.test(entry))
221                         || entry instanceof WidgetListSpaceEntry)
222                 .map(entry -> {
223                     if (entry instanceof WidgetsListBaseEntry.Header<?>
224                             && matchesKey(entry, mWidgetsContentVisiblePackageUserKey)) {
225                         // Adjust the original entries to expand headers for the selected content.
226                         return ((WidgetsListBaseEntry.Header<?>) entry).withWidgetListShown();
227                     } else if (entry instanceof WidgetsListContentEntry) {
228                         // Adjust the original content entries to accommodate for the current
229                         // maxSpanSize.
230                         return ((WidgetsListContentEntry) entry).withMaxSpanSize(mMaxSpanSize);
231                     }
232                     return entry;
233                 })
234                 .collect(Collectors.toList());
235 
236         mDiffReporter.process(mVisibleEntries, newVisibleEntries, mRowComparator);
237 
238         if (mPendingClickHeader != null) {
239             // Get the position for the clicked header after adjusting the visible entries. The
240             // position may have changed if another header had previously been expanded.
241             OptionalInt positionForPackageUserKey =
242                     getPositionForPackageUserKey(mPendingClickHeader);
243             scrollToPositionAndMaintainOffset(positionForPackageUserKey, topForPackageUserKey);
244             mPendingClickHeader = null;
245         }
246     }
247 
248 
249     /** Returns whether {@code entry} matches {@code key}. */
isHeaderForPackageUserKey( @onNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key)250     private static boolean isHeaderForPackageUserKey(
251             @NonNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key) {
252         return entry instanceof WidgetsListBaseEntry.Header && matchesKey(entry, key);
253     }
254 
matchesKey(@onNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key)255     private static boolean matchesKey(@NonNull WidgetsListBaseEntry entry,
256             @Nullable PackageUserKey key) {
257         if (key == null) return false;
258         return entry.mPkgItem.packageName.equals(key.mPackageName)
259                 && entry.mPkgItem.widgetCategory == key.mWidgetCategory
260                 && entry.mPkgItem.user.equals(key.mUser);
261     }
262 
263     /**
264      * Resets any expanded widget header.
265      */
resetExpandedHeader()266     public void resetExpandedHeader() {
267         if (mWidgetsContentVisiblePackageUserKey != null) {
268             mWidgetsContentVisiblePackageUserKey = null;
269             updateVisibleEntries();
270         }
271     }
272 
273     @Override
onBindViewHolder(ViewHolder holder, int position)274     public void onBindViewHolder(ViewHolder holder, int position) {
275         onBindViewHolder(holder, position, Collections.EMPTY_LIST);
276     }
277 
278     @Override
onBindViewHolder(ViewHolder holder, int pos, List<Object> payloads)279     public void onBindViewHolder(ViewHolder holder, int pos, List<Object> payloads) {
280         ViewHolderBinder viewHolderBinder = mViewHolderBinders.get(getItemViewType(pos));
281         WidgetsListBaseEntry entry = mVisibleEntries.get(pos);
282 
283         // The first entry has an empty space, count from second entries.
284         int listPos = (pos > 1) ? POSITION_DEFAULT : POSITION_FIRST;
285         if (pos == (getItemCount() - 1)) {
286             listPos |= POSITION_LAST;
287         }
288         viewHolderBinder.bindViewHolder(holder, mVisibleEntries.get(pos), listPos, payloads);
289         holder.itemView.setTag(R.id.tag_widget_entry, entry);
290     }
291 
292     @Override
onCreateViewHolder(ViewGroup parent, int viewType)293     public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
294         if (DEBUG) {
295             Log.v(TAG, "\nonCreateViewHolder");
296         }
297 
298         return mViewHolderBinders.get(viewType).newViewHolder(parent);
299     }
300 
301     @Override
onViewRecycled(ViewHolder holder)302     public void onViewRecycled(ViewHolder holder) {
303         mViewHolderBinders.get(holder.getItemViewType()).unbindViewHolder(holder);
304     }
305 
306     @Override
onFailedToRecycleView(ViewHolder holder)307     public boolean onFailedToRecycleView(ViewHolder holder) {
308         // If child views are animating, then the RecyclerView may choose not to recycle the view,
309         // causing extraneous onCreateViewHolder() calls.  It is safe in this case to continue
310         // recycling this view, and take care in onViewRecycled() to cancel any existing
311         // animations.
312         return true;
313     }
314 
315     @Override
getItemId(int pos)316     public long getItemId(int pos) {
317         return Arrays.hashCode(new Object[]{
318                 mVisibleEntries.get(pos).mPkgItem.hashCode(),
319                 getItemViewType(pos)});
320     }
321 
322     @Override
getItemViewType(int pos)323     public int getItemViewType(int pos) {
324         WidgetsListBaseEntry entry = mVisibleEntries.get(pos);
325         if (entry instanceof WidgetsListContentEntry) {
326             return VIEW_TYPE_WIDGETS_LIST;
327         } else if (entry instanceof WidgetsListHeaderEntry) {
328             return VIEW_TYPE_WIDGETS_HEADER;
329         } else if (entry instanceof WidgetsListSearchHeaderEntry) {
330             return VIEW_TYPE_WIDGETS_SEARCH_HEADER;
331         } else if (entry instanceof WidgetListSpaceEntry) {
332             return VIEW_TYPE_WIDGETS_SPACE;
333         }
334         throw new UnsupportedOperationException("ViewHolderBinder not found for " + entry);
335     }
336 
337     @Override
onHeaderClicked(boolean showWidgets, PackageUserKey packageUserKey)338     public void onHeaderClicked(boolean showWidgets, PackageUserKey packageUserKey) {
339         // Ignore invalid clicks, such as collapsing a package that isn't currently expanded.
340         if (!showWidgets && !packageUserKey.equals(mWidgetsContentVisiblePackageUserKey)) return;
341 
342         if (showWidgets) {
343             mWidgetsContentVisiblePackageUserKey = packageUserKey;
344             ActivityContext.lookupContext(mContext)
345                     .getStatsLogManager().logger().log(LAUNCHER_WIDGETSTRAY_APP_EXPANDED);
346         } else {
347             mWidgetsContentVisiblePackageUserKey = null;
348         }
349 
350         // Store the header that was clicked so that its position will be maintained the next time
351         // we update the entries.
352         mPendingClickHeader = packageUserKey;
353 
354         updateVisibleEntries();
355     }
356 
357     /**
358      * Returns the position of {@code key} in {@link #mVisibleEntries}, or  empty if it's not
359      * present.
360      */
361     @NonNull
getPositionForPackageUserKey(@ullable PackageUserKey key)362     private OptionalInt getPositionForPackageUserKey(@Nullable PackageUserKey key) {
363         return IntStream.range(0, mVisibleEntries.size())
364                 .filter(index -> isHeaderForPackageUserKey(mVisibleEntries.get(index), key))
365                 .findFirst();
366     }
367 
368     /**
369      * Returns the top of {@code positionOptional} in the recycler view, or empty if its view
370      * can't be found for any reason, including the position not being currently visible. The
371      * returned value does not include the top padding of the recycler view.
372      */
getOffsetForPosition(OptionalInt positionOptional)373     private OptionalInt getOffsetForPosition(OptionalInt positionOptional) {
374         if (!positionOptional.isPresent() || mRecyclerView == null) return OptionalInt.empty();
375 
376         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
377         if (layoutManager == null) return OptionalInt.empty();
378 
379         View view = layoutManager.findViewByPosition(positionOptional.getAsInt());
380         if (view == null) return OptionalInt.empty();
381 
382         return OptionalInt.of(layoutManager.getDecoratedTop(view));
383     }
384 
385     /**
386      * Scrolls to the selected header position with the provided offset. LinearLayoutManager
387      * scrolls the minimum distance necessary, so this will keep the selected header in place during
388      * clicks, without interrupting the animation.
389      *
390      * @param positionOptional The position too scroll to. No scrolling will be done if empty.
391      * @param offsetOptional The offset from the top to maintain. If empty, then the list will
392      *                       scroll to the top of the position.
393      */
scrollToPositionAndMaintainOffset( OptionalInt positionOptional, OptionalInt offsetOptional)394     private void scrollToPositionAndMaintainOffset(
395             OptionalInt positionOptional,
396             OptionalInt offsetOptional) {
397         if (!positionOptional.isPresent() || mRecyclerView == null) return;
398         int position = positionOptional.getAsInt();
399 
400         LinearLayoutManager layoutManager = (LinearLayoutManager) mRecyclerView.getLayoutManager();
401         if (layoutManager == null) return;
402 
403         if (position == mVisibleEntries.size() - 2
404                 && mVisibleEntries.get(mVisibleEntries.size() - 1)
405                 instanceof WidgetsListContentEntry) {
406             // If the selected header is in the last position and its content is showing, then
407             // scroll to the final position so the last list of widgets will show.
408             layoutManager.scrollToPosition(mVisibleEntries.size() - 1);
409             return;
410         }
411 
412         // Scroll to the header view's current offset, accounting for the recycler view's padding.
413         // If the header view couldn't be found, then it will appear at the top of the list.
414         layoutManager.scrollToPositionWithOffset(
415                 position,
416                 offsetOptional.orElse(0) - mRecyclerView.getPaddingTop());
417     }
418 
419     /**
420      * Sets the max horizontal span in cells that is allowed for grouping more than one widget in a
421      * table row.
422      */
setMaxHorizontalSpansPerRow(int maxHorizontalSpans)423     public void setMaxHorizontalSpansPerRow(int maxHorizontalSpans) {
424         mMaxSpanSize = maxHorizontalSpans;
425         updateVisibleEntries();
426     }
427 
428     /**
429      * Returns {@code true} if there is a change in {@link #mAllEntries} that results in an
430      * invalidation of {@link #mVisibleEntries}. e.g. there is change in the device language.
431      */
shouldClearVisibleEntries()432     private boolean shouldClearVisibleEntries() {
433         Map<PackageUserKey, PackageItemInfo> packagesInfo =
434                 mAllEntries.stream()
435                         .filter(entry -> entry instanceof WidgetsListHeaderEntry)
436                         .map(entry -> entry.mPkgItem)
437                         .collect(Collectors.toMap(
438                                 entry -> PackageUserKey.fromPackageItemInfo(entry),
439                                 entry -> entry));
440         for (WidgetsListBaseEntry visibleEntry: mVisibleEntries) {
441             PackageUserKey key = PackageUserKey.fromPackageItemInfo(visibleEntry.mPkgItem);
442             PackageItemInfo packageItemInfo = packagesInfo.get(key);
443             if (packageItemInfo != null
444                     && !visibleEntry.mPkgItem.title.equals(packageItemInfo.title)) {
445                 return true;
446             }
447         }
448         return false;
449     }
450 
451     /** Comparator for sorting WidgetListRowEntry based on package title. */
452     public static class WidgetListBaseRowEntryComparator implements
453             Comparator<WidgetsListBaseEntry> {
454 
455         private final LabelComparator mComparator = new LabelComparator();
456 
457         @Override
compare(WidgetsListBaseEntry a, WidgetsListBaseEntry b)458         public int compare(WidgetsListBaseEntry a, WidgetsListBaseEntry b) {
459             int i = mComparator.compare(a.mPkgItem.title.toString(), b.mPkgItem.title.toString());
460             if (i != 0) {
461                 return i;
462             }
463             // Prioritize entries from current user over other users if the entries are same.
464             if (a.mPkgItem.user.equals(b.mPkgItem.user)) return 0;
465             if (a.mPkgItem.user.equals(Process.myUserHandle())) return -1;
466             return 1;
467         }
468     }
469 }
470