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 import static com.android.launcher3.widget.BaseWidgetSheet.DEFAULT_MAX_HORIZONTAL_SPANS;
23 
24 import android.content.Context;
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.annotation.Px;
37 import androidx.recyclerview.widget.DiffUtil;
38 import androidx.recyclerview.widget.DiffUtil.DiffResult;
39 import androidx.recyclerview.widget.LinearLayoutManager;
40 import androidx.recyclerview.widget.RecyclerView;
41 import androidx.recyclerview.widget.RecyclerView.Adapter;
42 import androidx.recyclerview.widget.RecyclerView.ViewHolder;
43 
44 import com.android.launcher3.R;
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.util.WidgetSizes;
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.OptionalInt;
61 import java.util.function.IntSupplier;
62 import java.util.function.Predicate;
63 import java.util.stream.Collectors;
64 import java.util.stream.IntStream;
65 
66 /**
67  * Recycler view adapter for the widget tray.
68  *
69  * <p>This adapter supports view binding of subclasses of {@link WidgetsListBaseEntry}. There are 2
70  * subclasses: {@link WidgetsListHeader} & {@link WidgetsListContentEntry}.
71  * {@link WidgetsListHeader} entries are always visible in the recycler view. At most one
72  * {@link WidgetsListContentEntry} is shown in the recycler view at any time. Clicking a
73  * {@link WidgetsListHeader} will result in expanding / collapsing a corresponding
74  * {@link WidgetsListContentEntry} of the same app.
75  */
76 public class WidgetsListAdapter extends Adapter<ViewHolder> implements OnHeaderClickListener {
77 
78     private static final String TAG = "WidgetsListAdapter";
79     private static final boolean DEBUG = false;
80 
81     /** Uniquely identifies widgets list view type within the app. */
82     public static final int VIEW_TYPE_WIDGETS_SPACE = R.id.view_type_widgets_space;
83     public static final int VIEW_TYPE_WIDGETS_LIST = R.id.view_type_widgets_list;
84     public static final int VIEW_TYPE_WIDGETS_HEADER = R.id.view_type_widgets_header;
85 
86     private final Context mContext;
87     private final SparseArray<ViewHolderBinder> mViewHolderBinders = new SparseArray<>();
88     private final WidgetListBaseRowEntryComparator mRowComparator =
89             new WidgetListBaseRowEntryComparator();
90     @Nullable private WidgetsTwoPaneSheet.HeaderChangeListener mHeaderChangeListener;
91 
92     private final List<WidgetsListBaseEntry> mAllEntries = new ArrayList<>();
93     private ArrayList<WidgetsListBaseEntry> mVisibleEntries = new ArrayList<>();
94     @Nullable private PackageUserKey mWidgetsContentVisiblePackageUserKey = null;
95 
96     private Predicate<WidgetsListBaseEntry> mHeaderAndSelectedContentFilter = entry ->
97             entry instanceof WidgetsListHeaderEntry
98                     || PackageUserKey.fromPackageItemInfo(entry.mPkgItem)
99                             .equals(mWidgetsContentVisiblePackageUserKey);
100     @Nullable private Predicate<WidgetsListBaseEntry> mFilter = null;
101     @Nullable private RecyclerView mRecyclerView;
102     @Nullable private PackageUserKey mPendingClickHeader;
103     @Px private int mMaxHorizontalSpan;
104 
WidgetsListAdapter(Context context, LayoutInflater layoutInflater, IntSupplier emptySpaceHeightProvider, OnClickListener iconClickListener, OnLongClickListener iconLongClickListener, boolean isTwoPane)105     public WidgetsListAdapter(Context context, LayoutInflater layoutInflater,
106             IntSupplier emptySpaceHeightProvider, OnClickListener iconClickListener,
107             OnLongClickListener iconLongClickListener,
108             boolean isTwoPane) {
109         mContext = context;
110         mMaxHorizontalSpan = WidgetSizes.getWidgetSizePx(
111                 ActivityContext.lookupContext(context).getDeviceProfile(),
112                         DEFAULT_MAX_HORIZONTAL_SPANS, 1).getWidth();
113 
114         mViewHolderBinders.put(
115                 VIEW_TYPE_WIDGETS_LIST,
116                 new WidgetsListTableViewHolderBinder(
117                         mContext, layoutInflater, iconClickListener, iconLongClickListener));
118         mViewHolderBinders.put(
119                 VIEW_TYPE_WIDGETS_HEADER,
120                 new WidgetsListHeaderViewHolderBinder(
121                         layoutInflater, /* onHeaderClickListener= */ this,
122                         isTwoPane));
123         mViewHolderBinders.put(
124                 VIEW_TYPE_WIDGETS_SPACE,
125                 new WidgetsSpaceViewHolderBinder(emptySpaceHeightProvider));
126     }
127 
setHeaderChangeListener(WidgetsTwoPaneSheet.HeaderChangeListener headerChangeListener)128     public void setHeaderChangeListener(WidgetsTwoPaneSheet.HeaderChangeListener
129             headerChangeListener) {
130         mHeaderChangeListener = headerChangeListener;
131     }
132 
133     @Override
onAttachedToRecyclerView(@onNull RecyclerView recyclerView)134     public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
135         mRecyclerView = recyclerView;
136     }
137 
138     @Override
onDetachedFromRecyclerView(@onNull RecyclerView recyclerView)139     public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
140         mRecyclerView = null;
141     }
142 
setFilter(Predicate<WidgetsListBaseEntry> filter)143     public void setFilter(Predicate<WidgetsListBaseEntry> filter) {
144         mFilter = filter;
145     }
146 
147     @Override
getItemCount()148     public int getItemCount() {
149         return mVisibleEntries.size();
150     }
151 
152     /**
153      * Returns true if the adapter has entries which will be visible to the user
154      */
hasVisibleEntries()155     public boolean hasVisibleEntries() {
156         // Account for the 1st space entry
157         return getItemCount() > 1;
158     }
159 
160     /** Returns all items that will be drawn in a recycler view. */
getItems()161     public List<WidgetsListBaseEntry> getItems() {
162         return mVisibleEntries;
163     }
164 
165     /** Gets the section name for {@link com.android.launcher3.views.RecyclerViewFastScroller}. */
getSectionName(int pos)166     public String getSectionName(int pos) {
167         return mVisibleEntries.get(pos).mTitleSectionName;
168     }
169 
170     /** Updates the widget list based on {@code tempEntries}. */
setWidgets(List<WidgetsListBaseEntry> tempEntries)171     public void setWidgets(List<WidgetsListBaseEntry> tempEntries) {
172         mAllEntries.clear();
173         mAllEntries.add(new WidgetListSpaceEntry());
174         tempEntries.stream().sorted(mRowComparator).forEach(mAllEntries::add);
175         updateVisibleEntries();
176     }
177 
178     /** Updates the widget list based on {@code searchResults}. */
setWidgetsOnSearch(List<WidgetsListBaseEntry> searchResults)179     public void setWidgetsOnSearch(List<WidgetsListBaseEntry> searchResults) {
180         // Forget the expanded package every time widget list is refreshed in search mode.
181         mWidgetsContentVisiblePackageUserKey = null;
182         setWidgets(searchResults);
183     }
184 
updateVisibleEntries()185     private void updateVisibleEntries() {
186         // Get the current top of the header with the matching key before adjusting the visible
187         // entries.
188         OptionalInt previousPositionForPackageUserKey =
189                 getPositionForPackageUserKey(mPendingClickHeader);
190         OptionalInt topForPackageUserKey =
191                 getOffsetForPosition(previousPositionForPackageUserKey);
192 
193         List<WidgetsListBaseEntry> newVisibleEntries = mAllEntries.stream()
194                 .filter(entry -> (((mFilter == null || mFilter.test(entry))
195                         && mHeaderAndSelectedContentFilter.test(entry))
196                         || entry instanceof WidgetListSpaceEntry)
197                         && (mHeaderChangeListener == null
198                         || !(entry instanceof WidgetsListContentEntry)))
199                 .map(entry -> {
200                     if (entry instanceof WidgetsListHeaderEntry
201                             && matchesKey(entry, mWidgetsContentVisiblePackageUserKey)) {
202                         // Adjust the original entries to expand headers for the selected content.
203                         return ((WidgetsListHeaderEntry) entry).withWidgetListShown();
204                     } else if (entry instanceof WidgetsListContentEntry) {
205                         // Adjust the original content entries to accommodate for the current
206                         // maxSpanSize.
207                         return ((WidgetsListContentEntry) entry).withMaxSpanSize(
208                                 mMaxHorizontalSpan);
209                     }
210                     return entry;
211                 })
212                 .collect(Collectors.toList());
213 
214         DiffResult diffResult = DiffUtil.calculateDiff(
215                 new WidgetsDiffCallback(mVisibleEntries, newVisibleEntries), false);
216         mVisibleEntries.clear();
217         mVisibleEntries.addAll(newVisibleEntries);
218         diffResult.dispatchUpdatesTo(this);
219 
220         if (mPendingClickHeader != null) {
221             // Get the position for the clicked header after adjusting the visible entries. The
222             // position may have changed if another header had previously been expanded.
223             OptionalInt positionForPackageUserKey =
224                     getPositionForPackageUserKey(mPendingClickHeader);
225             scrollToPositionAndMaintainOffset(positionForPackageUserKey, topForPackageUserKey);
226             mPendingClickHeader = null;
227         }
228     }
229 
230     /** Returns whether {@code entry} matches {@code key}. */
isHeaderForPackageUserKey( @onNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key)231     private static boolean isHeaderForPackageUserKey(
232             @NonNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key) {
233         return entry instanceof WidgetsListHeaderEntry && matchesKey(entry, key);
234     }
235 
matchesKey(@onNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key)236     private static boolean matchesKey(@NonNull WidgetsListBaseEntry entry,
237             @Nullable PackageUserKey key) {
238         if (key == null) return false;
239         return entry.mPkgItem.packageName.equals(key.mPackageName)
240                 && entry.mPkgItem.widgetCategory == key.mWidgetCategory
241                 && entry.mPkgItem.user.equals(key.mUser);
242     }
243 
244     /**
245      * Resets any expanded widget header.
246      */
resetExpandedHeader()247     public void resetExpandedHeader() {
248         if (mWidgetsContentVisiblePackageUserKey != null) {
249             mWidgetsContentVisiblePackageUserKey = null;
250             updateVisibleEntries();
251         }
252     }
253 
254     @Override
onBindViewHolder(ViewHolder holder, int position)255     public void onBindViewHolder(ViewHolder holder, int position) {
256         onBindViewHolder(holder, position, Collections.EMPTY_LIST);
257     }
258 
259     @Override
onBindViewHolder(ViewHolder holder, int pos, List<Object> payloads)260     public void onBindViewHolder(ViewHolder holder, int pos, List<Object> payloads) {
261         ViewHolderBinder viewHolderBinder = mViewHolderBinders.get(getItemViewType(pos));
262 
263         // The first entry has an empty space, count from second entries.
264         int listPos = (pos > 1) ? POSITION_DEFAULT : POSITION_FIRST;
265         if (pos == (getItemCount() - 1)) {
266             listPos |= POSITION_LAST;
267         }
268         viewHolderBinder.bindViewHolder(holder, mVisibleEntries.get(pos), listPos, payloads);
269     }
270 
271     /**
272      * Selects the first visible header. This is used in search as we want to always select the
273      * first header in the new list that gets generated as we search.
274      */
selectFirstHeaderEntry()275     void selectFirstHeaderEntry() {
276         mVisibleEntries.stream()
277                 .filter(entry -> entry instanceof WidgetsListHeaderEntry)
278                 .findFirst()
279                 .ifPresent(entry ->
280                         onHeaderClicked(true, PackageUserKey.fromPackageItemInfo(entry.mPkgItem)));
281     }
282 
283     @Override
onCreateViewHolder(ViewGroup parent, int viewType)284     public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
285         if (DEBUG) {
286             Log.v(TAG, "\nonCreateViewHolder");
287         }
288 
289         return mViewHolderBinders.get(viewType).newViewHolder(parent);
290     }
291 
292     @Override
onViewRecycled(ViewHolder holder)293     public void onViewRecycled(ViewHolder holder) {
294         mViewHolderBinders.get(holder.getItemViewType()).unbindViewHolder(holder);
295     }
296 
297     @Override
onFailedToRecycleView(ViewHolder holder)298     public boolean onFailedToRecycleView(ViewHolder holder) {
299         // If child views are animating, then the RecyclerView may choose not to recycle the view,
300         // causing extraneous onCreateViewHolder() calls.  It is safe in this case to continue
301         // recycling this view, and take care in onViewRecycled() to cancel any existing
302         // animations.
303         return true;
304     }
305 
306     @Override
getItemId(int pos)307     public long getItemId(int pos) {
308         return Arrays.hashCode(new Object[]{
309                 mVisibleEntries.get(pos).mPkgItem.hashCode(),
310                 getItemViewType(pos)});
311     }
312 
313     @Override
getItemViewType(int pos)314     public int getItemViewType(int pos) {
315         WidgetsListBaseEntry entry = mVisibleEntries.get(pos);
316         if (entry instanceof WidgetsListContentEntry) {
317             return VIEW_TYPE_WIDGETS_LIST;
318         } else if (entry instanceof WidgetsListHeaderEntry) {
319             return VIEW_TYPE_WIDGETS_HEADER;
320         } else if (entry instanceof WidgetListSpaceEntry) {
321             return VIEW_TYPE_WIDGETS_SPACE;
322         }
323         throw new UnsupportedOperationException("ViewHolderBinder not found for " + entry);
324     }
325 
326     @Override
onHeaderClicked(boolean showWidgets, PackageUserKey packageUserKey)327     public void onHeaderClicked(boolean showWidgets, PackageUserKey packageUserKey) {
328         // Ignore invalid clicks, such as collapsing a package that isn't currently expanded.
329         if (!showWidgets && !packageUserKey.equals(mWidgetsContentVisiblePackageUserKey)) return;
330 
331         if (mHeaderChangeListener != null
332                 && packageUserKey.equals(mWidgetsContentVisiblePackageUserKey)) return;
333 
334         if (showWidgets) {
335             mWidgetsContentVisiblePackageUserKey = packageUserKey;
336             ActivityContext.lookupContext(mContext)
337                     .getStatsLogManager().logger().log(LAUNCHER_WIDGETSTRAY_APP_EXPANDED);
338         } else {
339             mWidgetsContentVisiblePackageUserKey = null;
340         }
341 
342         // Store the header that was clicked so that its position will be maintained the next time
343         // we update the entries.
344         mPendingClickHeader = packageUserKey;
345 
346         updateVisibleEntries();
347 
348         if (mHeaderChangeListener != null && mWidgetsContentVisiblePackageUserKey != null) {
349             mHeaderChangeListener.onHeaderChanged(mWidgetsContentVisiblePackageUserKey);
350         }
351     }
352 
353     /**
354      * Returns the position of {@code key} in {@link #mVisibleEntries}, or  empty if it's not
355      * present.
356      */
357     @NonNull
getPositionForPackageUserKey(@ullable PackageUserKey key)358     private OptionalInt getPositionForPackageUserKey(@Nullable PackageUserKey key) {
359         return IntStream.range(0, mVisibleEntries.size())
360                 .filter(index -> isHeaderForPackageUserKey(mVisibleEntries.get(index), key))
361                 .findFirst();
362     }
363 
364     /**
365      * Returns the top of {@code positionOptional} in the recycler view, or empty if its view
366      * can't be found for any reason, including the position not being currently visible. The
367      * returned value does not include the top padding of the recycler view.
368      */
getOffsetForPosition(OptionalInt positionOptional)369     private OptionalInt getOffsetForPosition(OptionalInt positionOptional) {
370         if (!positionOptional.isPresent() || mRecyclerView == null) return OptionalInt.empty();
371 
372         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
373         if (layoutManager == null) return OptionalInt.empty();
374 
375         View view = layoutManager.findViewByPosition(positionOptional.getAsInt());
376         if (view == null) return OptionalInt.empty();
377 
378         return OptionalInt.of(layoutManager.getDecoratedTop(view));
379     }
380 
381     /**
382      * Scrolls to the selected header position with the provided offset. LinearLayoutManager
383      * scrolls the minimum distance necessary, so this will keep the selected header in place during
384      * clicks, without interrupting the animation.
385      *
386      * @param positionOptional The position too scroll to. No scrolling will be done if empty.
387      * @param offsetOptional The offset from the top to maintain. If empty, then the list will
388      *                       scroll to the top of the position.
389      */
scrollToPositionAndMaintainOffset( OptionalInt positionOptional, OptionalInt offsetOptional)390     private void scrollToPositionAndMaintainOffset(
391             OptionalInt positionOptional,
392             OptionalInt offsetOptional) {
393         if (!positionOptional.isPresent() || mRecyclerView == null) return;
394         int position = positionOptional.getAsInt();
395 
396         LinearLayoutManager layoutManager = (LinearLayoutManager) mRecyclerView.getLayoutManager();
397         if (layoutManager == null) return;
398 
399         if (position == mVisibleEntries.size() - 2
400                 && mVisibleEntries.get(mVisibleEntries.size() - 1)
401                 instanceof WidgetsListContentEntry) {
402             // If the selected header is in the last position and its content is showing, then
403             // scroll to the final position so the last list of widgets will show.
404             layoutManager.scrollToPosition(mVisibleEntries.size() - 1);
405             return;
406         }
407 
408         // Scroll to the header view's current offset, accounting for the recycler view's padding.
409         // If the header view couldn't be found, then it will appear at the top of the list.
410         layoutManager.scrollToPositionWithOffset(
411                 position,
412                 offsetOptional.orElse(0) - mRecyclerView.getPaddingTop());
413     }
414 
415     /**
416      * Sets the max horizontal span in pixels that is allowed for grouping more than one widget in a
417      * table row.
418      */
setMaxHorizontalSpansPxPerRow(@x int maxHorizontalSpan)419     public void setMaxHorizontalSpansPxPerRow(@Px int maxHorizontalSpan) {
420         mMaxHorizontalSpan = maxHorizontalSpan;
421         updateVisibleEntries();
422     }
423 
424     /** Comparator for sorting WidgetListRowEntry based on package title. */
425     public static class WidgetListBaseRowEntryComparator implements
426             Comparator<WidgetsListBaseEntry> {
427 
428         private final LabelComparator mComparator = new LabelComparator();
429 
430         @Override
compare(WidgetsListBaseEntry a, WidgetsListBaseEntry b)431         public int compare(WidgetsListBaseEntry a, WidgetsListBaseEntry b) {
432             int i = mComparator.compare(a.mPkgItem.title.toString(), b.mPkgItem.title.toString());
433             if (i != 0) {
434                 return i;
435             }
436             // Prioritize entries from current user over other users if the entries are same.
437             if (a.mPkgItem.user.equals(b.mPkgItem.user)) return 0;
438             if (a.mPkgItem.user.equals(Process.myUserHandle())) return -1;
439             return 1;
440         }
441     }
442 }
443