AlphabeticalAppsList.java revision 5683f871722254e4e357cf3fb77cd28156278e51
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 */
16package com.android.launcher3.allapps;
17
18import android.content.ComponentName;
19import android.content.Context;
20import android.support.v7.widget.RecyclerView;
21import android.util.Log;
22
23import com.android.launcher3.AppInfo;
24import com.android.launcher3.DeviceProfile;
25import com.android.launcher3.Launcher;
26import com.android.launcher3.LauncherAppState;
27import com.android.launcher3.compat.AlphabeticIndexCompat;
28import com.android.launcher3.model.AbstractUserComparator;
29import com.android.launcher3.model.AppNameComparator;
30
31import java.nio.charset.CharsetEncoder;
32import java.nio.charset.StandardCharsets;
33import java.util.ArrayList;
34import java.util.Collections;
35import java.util.HashMap;
36import java.util.List;
37import java.util.Locale;
38import java.util.Map;
39import java.util.TreeMap;
40
41/**
42 * The alphabetically sorted list of applications.
43 */
44public class AlphabeticalAppsList {
45
46    public static final String TAG = "AlphabeticalAppsList";
47    private static final boolean DEBUG = false;
48
49    /**
50     * Info about a section in the alphabetic list
51     */
52    public static class SectionInfo {
53        // The number of applications in this section
54        public int numApps;
55        // The section break AdapterItem for this section
56        public AdapterItem sectionBreakItem;
57        // The first app AdapterItem for this section
58        public AdapterItem firstAppItem;
59    }
60
61    /**
62     * Info about a fast scroller section, depending if sections are merged, the fast scroller
63     * sections will not be the same set as the section headers.
64     */
65    public static class FastScrollSectionInfo {
66        // The section name
67        public String sectionName;
68        // To map the touch (from 0..1) to the index in the app list to jump to in the fast
69        // scroller, we use the fraction in range (0..1) of the app index / total app count.
70        public float appRangeFraction;
71        // The AdapterItem to scroll to for this section
72        public AdapterItem appItem;
73
74        public FastScrollSectionInfo(String sectionName, float appRangeFraction) {
75            this.sectionName = sectionName;
76            this.appRangeFraction = appRangeFraction;
77        }
78    }
79
80    /**
81     * Info about a particular adapter item (can be either section or app)
82     */
83    public static class AdapterItem {
84        /** Common properties */
85        // The index of this adapter item in the list
86        public int position;
87        // The type of this item
88        public int viewType;
89
90        /** Section & App properties */
91        // The section for this item
92        public SectionInfo sectionInfo;
93
94        /** App-only properties */
95        // The section name of this app.  Note that there can be multiple items with different
96        // sectionNames in the same section
97        public String sectionName = null;
98        // The index of this app in the section
99        public int sectionAppIndex = -1;
100        // The associated AppInfo for the app
101        public AppInfo appInfo = null;
102        // The index of this app not including sections
103        public int appIndex = -1;
104
105        public static AdapterItem asSectionBreak(int pos, SectionInfo section) {
106            AdapterItem item = new AdapterItem();
107            item.viewType = AllAppsGridAdapter.SECTION_BREAK_VIEW_TYPE;
108            item.position = pos;
109            item.sectionInfo = section;
110            section.sectionBreakItem = item;
111            return item;
112        }
113
114        public static AdapterItem asPredictionBarSpacer(int pos) {
115            AdapterItem item = new AdapterItem();
116            item.viewType = AllAppsGridAdapter.PREDICTION_BAR_SPACER_TYPE;
117            item.position = pos;
118            return item;
119        }
120
121        public static AdapterItem asApp(int pos, SectionInfo section, String sectionName,
122                                        int sectionAppIndex, AppInfo appInfo, int appIndex) {
123            AdapterItem item = new AdapterItem();
124            item.viewType = AllAppsGridAdapter.ICON_VIEW_TYPE;
125            item.position = pos;
126            item.sectionInfo = section;
127            item.sectionName = sectionName;
128            item.sectionAppIndex = sectionAppIndex;
129            item.appInfo = appInfo;
130            item.appIndex = appIndex;
131            return item;
132        }
133    }
134
135    /**
136     * Callback to notify when the set of adapter items have changed.
137     */
138    public interface AdapterChangedCallback {
139        void onAdapterItemsChanged();
140    }
141
142    /**
143     * Common interface for different merging strategies.
144     */
145    private interface MergeAlgorithm {
146        boolean continueMerging(SectionInfo section, SectionInfo withSection,
147                int sectionAppCount, int numAppsPerRow, int mergeCount);
148    }
149
150    /**
151     * The logic we use to merge sections on tablets.  Currently, we don't show section names on
152     * tablet layouts, so just merge all the sections indiscriminately.
153     */
154    private static class TabletMergeAlgorithm implements MergeAlgorithm {
155
156        @Override
157        public boolean continueMerging(SectionInfo section, SectionInfo withSection,
158                int sectionAppCount, int numAppsPerRow, int mergeCount) {
159            // Merge EVERYTHING
160            return true;
161        }
162    }
163
164    /**
165     * The logic we use to merge sections on phones.  We only merge sections when their final row
166     * contains less than a certain number of icons, and stop at a specified max number of merges.
167     * In addition, we will try and not merge sections that identify apps from different scripts.
168     */
169    private static class PhoneMergeAlgorithm implements MergeAlgorithm {
170
171        private int mMinAppsPerRow;
172        private int mMinRowsInMergedSection;
173        private int mMaxAllowableMerges;
174        private CharsetEncoder mAsciiEncoder;
175
176        public PhoneMergeAlgorithm(int minAppsPerRow, int minRowsInMergedSection, int maxNumMerges) {
177            mMinAppsPerRow = minAppsPerRow;
178            mMinRowsInMergedSection = minRowsInMergedSection;
179            mMaxAllowableMerges = maxNumMerges;
180            mAsciiEncoder = StandardCharsets.US_ASCII.newEncoder();
181        }
182
183        @Override
184        public boolean continueMerging(SectionInfo section, SectionInfo withSection,
185                int sectionAppCount, int numAppsPerRow, int mergeCount) {
186            // Continue merging if the number of hanging apps on the final row is less than some
187            // fixed number (ragged), the merged rows has yet to exceed some minimum row count,
188            // and while the number of merged sections is less than some fixed number of merges
189            int rows = sectionAppCount / numAppsPerRow;
190            int cols = sectionAppCount % numAppsPerRow;
191
192            // Ensure that we do not merge across scripts, currently we only allow for english and
193            // native scripts so we can test if both can just be ascii encoded
194            boolean isCrossScript = false;
195            if (section.firstAppItem != null && withSection.firstAppItem != null) {
196                isCrossScript = mAsciiEncoder.canEncode(section.firstAppItem.sectionName) !=
197                        mAsciiEncoder.canEncode(withSection.firstAppItem.sectionName);
198            }
199            return (0 < cols && cols < mMinAppsPerRow) &&
200                    rows < mMinRowsInMergedSection &&
201                    mergeCount < mMaxAllowableMerges &&
202                    !isCrossScript;
203        }
204    }
205
206    private static final int MIN_ROWS_IN_MERGED_SECTION_PHONE = 3;
207    private static final int MAX_NUM_MERGES_PHONE = 2;
208
209    private Launcher mLauncher;
210
211    // The set of apps from the system not including predictions
212    private final List<AppInfo> mApps = new ArrayList<>();
213    // The set of filtered apps with the current filter
214    private List<AppInfo> mFilteredApps = new ArrayList<>();
215    // The current set of adapter items
216    private List<AdapterItem> mAdapterItems = new ArrayList<>();
217    // The set of sections for the apps with the current filter
218    private List<SectionInfo> mSections = new ArrayList<>();
219    // The set of sections that we allow fast-scrolling to (includes non-merged sections)
220    private List<FastScrollSectionInfo> mFastScrollerSections = new ArrayList<>();
221    // The set of predicted app component names
222    private List<ComponentName> mPredictedAppComponents = new ArrayList<>();
223    // The set of predicted apps resolved from the component names and the current set of apps
224    private List<AppInfo> mPredictedApps = new ArrayList<>();
225    // The of ordered component names as a result of a search query
226    private ArrayList<ComponentName> mSearchResults;
227    private HashMap<CharSequence, String> mCachedSectionNames = new HashMap<>();
228    private RecyclerView.Adapter mAdapter;
229    private AlphabeticIndexCompat mIndexer;
230    private AppNameComparator mAppNameComparator;
231    private MergeAlgorithm mMergeAlgorithm;
232    private AdapterChangedCallback mAdapterChangedCallback;
233    private int mNumAppsPerRow;
234    private int mNumPredictedAppsPerRow;
235
236    public AlphabeticalAppsList(Context context, int numAppsPerRow, int numPredictedAppsPerRow) {
237        mLauncher = (Launcher) context;
238        mIndexer = new AlphabeticIndexCompat(context);
239        mAppNameComparator = new AppNameComparator(context);
240        setNumAppsPerRow(numAppsPerRow, numPredictedAppsPerRow);
241    }
242
243    /**
244     * Sets the apps updated callback.
245     */
246    public void setAdapterChangedCallback(AdapterChangedCallback cb) {
247        mAdapterChangedCallback = cb;
248    }
249
250    public SimpleAppSearchManagerImpl newSimpleAppSearchManager() {
251        return new SimpleAppSearchManagerImpl(mApps);
252    }
253
254    /**
255     * Sets the number of apps per row.  Used only for AppsContainerView.SECTIONED_GRID_COALESCED.
256     */
257    public void setNumAppsPerRow(int numAppsPerRow, int numPredictedAppsPerRow) {
258        // Update the merge algorithm
259        DeviceProfile grid = mLauncher.getDeviceProfile();
260        if (grid.isPhone) {
261            mMergeAlgorithm = new PhoneMergeAlgorithm((int) Math.ceil(numAppsPerRow / 2f),
262                    MIN_ROWS_IN_MERGED_SECTION_PHONE, MAX_NUM_MERGES_PHONE);
263        } else {
264            mMergeAlgorithm = new TabletMergeAlgorithm();
265        }
266
267        mNumAppsPerRow = numAppsPerRow;
268        mNumPredictedAppsPerRow = numPredictedAppsPerRow;
269
270        onAppsUpdated();
271    }
272
273    /**
274     * Sets the adapter to notify when this dataset changes.
275     */
276    public void setAdapter(RecyclerView.Adapter adapter) {
277        mAdapter = adapter;
278    }
279
280    /**
281     * Returns sections of all the current filtered applications.
282     */
283    public List<SectionInfo> getSections() {
284        return mSections;
285    }
286
287    /**
288     * Returns fast scroller sections of all the current filtered applications.
289     */
290    public List<FastScrollSectionInfo> getFastScrollerSections() {
291        return mFastScrollerSections;
292    }
293
294    /**
295     * Returns the current filtered list of applications broken down into their sections.
296     */
297    public List<AdapterItem> getAdapterItems() {
298        return mAdapterItems;
299    }
300
301    /**
302     * Returns the number of applications in this list.
303     */
304    public int getSize() {
305        return mFilteredApps.size();
306    }
307
308    /**
309     * Returns whether there are is a filter set.
310     */
311    public boolean hasFilter() {
312        return (mSearchResults != null);
313    }
314
315    /**
316     * Returns whether there are no filtered results.
317     */
318    public boolean hasNoFilteredResults() {
319        return (mSearchResults != null) && mFilteredApps.isEmpty();
320    }
321
322    /**
323     * Sets the sorted list of filtered components.
324     */
325    public void setOrderedFilter(ArrayList<ComponentName> f) {
326        if (mSearchResults != f) {
327            mSearchResults = f;
328            updateAdapterItems();
329        }
330    }
331
332    /**
333     * Sets the current set of predicted apps.  Since this can be called before we get the full set
334     * of applications, we should merge the results only in onAppsUpdated() which is idempotent.
335     */
336    public void setPredictedApps(List<ComponentName> apps) {
337        mPredictedAppComponents.clear();
338        mPredictedAppComponents.addAll(apps);
339        onAppsUpdated();
340    }
341
342    /**
343     * Returns the current set of predicted apps.
344     */
345    public List<AppInfo> getPredictedApps() {
346        return mPredictedApps;
347    }
348
349    /**
350     * Sets the current set of apps.
351     */
352    public void setApps(List<AppInfo> apps) {
353        mApps.clear();
354        mApps.addAll(apps);
355        onAppsUpdated();
356    }
357
358    /**
359     * Adds new apps to the list.
360     */
361    public void addApps(List<AppInfo> apps) {
362        // We add it in place, in alphabetical order
363        for (AppInfo info : apps) {
364            mApps.add(info);
365        }
366        onAppsUpdated();
367    }
368
369    /**
370     * Updates existing apps in the list
371     */
372    public void updateApps(List<AppInfo> apps) {
373        for (AppInfo info : apps) {
374            int index = mApps.indexOf(info);
375            if (index != -1) {
376                mApps.set(index, info);
377            } else {
378                mApps.add(info);
379            }
380        }
381        onAppsUpdated();
382    }
383
384    /**
385     * Removes some apps from the list.
386     */
387    public void removeApps(List<AppInfo> apps) {
388        for (AppInfo info : apps) {
389            int removeIndex = findAppByComponent(mApps, info);
390            if (removeIndex != -1) {
391                mApps.remove(removeIndex);
392            }
393        }
394        onAppsUpdated();
395    }
396
397    /**
398     * Finds the index of an app given a target AppInfo.
399     */
400    private int findAppByComponent(List<AppInfo> apps, AppInfo targetInfo) {
401        ComponentName targetComponent = targetInfo.intent.getComponent();
402        int length = apps.size();
403        for (int i = 0; i < length; ++i) {
404            AppInfo info = apps.get(i);
405            if (info.user.equals(targetInfo.user)
406                    && info.intent.getComponent().equals(targetComponent)) {
407                return i;
408            }
409        }
410        return -1;
411    }
412
413    /**
414     * Updates internals when the set of apps are updated.
415     */
416    private void onAppsUpdated() {
417        // Sort the list of apps
418        Collections.sort(mApps, mAppNameComparator.getAppInfoComparator());
419
420        // As a special case for some languages (currently only Simplified Chinese), we may need to
421        // coalesce sections
422        Locale curLocale = mLauncher.getResources().getConfiguration().locale;
423        TreeMap<String, ArrayList<AppInfo>> sectionMap = null;
424        boolean localeRequiresSectionSorting = curLocale.equals(Locale.SIMPLIFIED_CHINESE);
425        if (localeRequiresSectionSorting) {
426            // Compute the section headers.  We use a TreeMap with the section name comparator to
427            // ensure that the sections are ordered when we iterate over it later
428            sectionMap = new TreeMap<>(mAppNameComparator.getSectionNameComparator());
429            for (AppInfo info : mApps) {
430                // Add the section to the cache
431                String sectionName = getAndUpdateCachedSectionName(info.title);
432
433                // Add it to the mapping
434                ArrayList<AppInfo> sectionApps = sectionMap.get(sectionName);
435                if (sectionApps == null) {
436                    sectionApps = new ArrayList<>();
437                    sectionMap.put(sectionName, sectionApps);
438                }
439                sectionApps.add(info);
440            }
441
442            // Add each of the section apps to the list in order
443            List<AppInfo> allApps = new ArrayList<>(mApps.size());
444            for (Map.Entry<String, ArrayList<AppInfo>> entry : sectionMap.entrySet()) {
445                allApps.addAll(entry.getValue());
446            }
447
448            mApps.clear();
449            mApps.addAll(allApps);
450        } else {
451            // Just compute the section headers for use below
452            for (AppInfo info : mApps) {
453                // Add the section to the cache
454                getAndUpdateCachedSectionName(info.title);
455            }
456        }
457
458        // Recompose the set of adapter items from the current set of apps
459        updateAdapterItems();
460    }
461
462    /**
463     * Updates the set of filtered apps with the current filter.  At this point, we expect
464     * mCachedSectionNames to have been calculated for the set of all apps in mApps.
465     */
466    private void updateAdapterItems() {
467        SectionInfo lastSectionInfo = null;
468        String lastSectionName = null;
469        FastScrollSectionInfo lastFastScrollerSectionInfo = null;
470        int position = 0;
471        int appIndex = 0;
472
473        // Prepare to update the list of sections, filtered apps, etc.
474        mFilteredApps.clear();
475        mFastScrollerSections.clear();
476        mAdapterItems.clear();
477        mSections.clear();
478
479        // Process the predicted app components
480        mPredictedApps.clear();
481        if (mPredictedAppComponents != null && !mPredictedAppComponents.isEmpty() && !hasFilter()) {
482            for (ComponentName cn : mPredictedAppComponents) {
483                for (AppInfo info : mApps) {
484                    if (cn.equals(info.componentName)) {
485                        mPredictedApps.add(info);
486                        break;
487                    }
488                }
489                // Stop at the number of predicted apps
490                if (mPredictedApps.size() == mNumPredictedAppsPerRow) {
491                    break;
492                }
493            }
494
495            if (!mPredictedApps.isEmpty()) {
496                // Create a new spacer for the prediction bar
497                AdapterItem sectionItem = AdapterItem.asPredictionBarSpacer(position++);
498                mAdapterItems.add(sectionItem);
499            }
500        }
501
502        // Recreate the filtered and sectioned apps (for convenience for the grid layout) from the
503        // ordered set of sections
504        List<AppInfo> apps = getFiltersAppInfos();
505        int numApps = apps.size();
506        for (int i = 0; i < numApps; i++) {
507            AppInfo info = apps.get(i);
508            String sectionName = getAndUpdateCachedSectionName(info.title);
509
510            // Create a new section if the section names do not match
511            if (lastSectionInfo == null || !sectionName.equals(lastSectionName)) {
512                lastSectionName = sectionName;
513                lastSectionInfo = new SectionInfo();
514                lastFastScrollerSectionInfo = new FastScrollSectionInfo(sectionName,
515                        (float) appIndex / numApps);
516                mSections.add(lastSectionInfo);
517                mFastScrollerSections.add(lastFastScrollerSectionInfo);
518
519                // Create a new section item to break the flow of items in the list
520                if (!hasFilter()) {
521                    AdapterItem sectionItem = AdapterItem.asSectionBreak(position++, lastSectionInfo);
522                    mAdapterItems.add(sectionItem);
523                }
524            }
525
526            // Create an app item
527            AdapterItem appItem = AdapterItem.asApp(position++, lastSectionInfo, sectionName,
528                    lastSectionInfo.numApps++, info, appIndex++);
529            if (lastSectionInfo.firstAppItem == null) {
530                lastSectionInfo.firstAppItem = appItem;
531                lastFastScrollerSectionInfo.appItem = appItem;
532            }
533            mAdapterItems.add(appItem);
534            mFilteredApps.add(info);
535        }
536
537        // Merge multiple sections together as requested by the merge strategy for this device
538        mergeSections();
539
540        // Refresh the recycler view
541        if (mAdapter != null) {
542            mAdapter.notifyDataSetChanged();
543        }
544
545        if (mAdapterChangedCallback != null) {
546            mAdapterChangedCallback.onAdapterItemsChanged();
547        }
548    }
549
550    private List<AppInfo> getFiltersAppInfos() {
551        if (mSearchResults == null) {
552            return mApps;
553        }
554
555        int total = mSearchResults.size();
556        final HashMap<ComponentName, Integer> sortOrder = new HashMap<>(total);
557        for (int i = 0; i < total; i++) {
558            sortOrder.put(mSearchResults.get(i), i);
559        }
560
561        ArrayList<AppInfo> result = new ArrayList<>();
562        for (AppInfo info : mApps) {
563            if (sortOrder.containsKey(info.componentName)) {
564                result.add(info);
565            }
566        }
567
568        Collections.sort(result, new AbstractUserComparator<AppInfo>(
569                LauncherAppState.getInstance().getContext()) {
570
571            @Override
572            public int compare(AppInfo lhs, AppInfo rhs) {
573                Integer indexA = sortOrder.get(lhs.componentName);
574                int result = indexA.compareTo(sortOrder.get(rhs.componentName));
575                if (result == 0) {
576                    return super.compare(lhs, rhs);
577                } else {
578                    return result;
579                }
580            }
581        });
582        return result;
583    }
584
585    /**
586     * Merges multiple sections to reduce visual raggedness.
587     */
588    private void mergeSections() {
589        // Go through each section and try and merge some of the sections
590        if (AllAppsContainerView.GRID_MERGE_SECTIONS && !hasFilter()) {
591            int sectionAppCount = 0;
592            for (int i = 0; i < mSections.size() - 1; i++) {
593                SectionInfo section = mSections.get(i);
594                sectionAppCount = section.numApps;
595                int mergeCount = 1;
596
597                // Merge rows based on the current strategy
598                while (i < (mSections.size() - 1) &&
599                        mMergeAlgorithm.continueMerging(section, mSections.get(i + 1),
600                                sectionAppCount, mNumAppsPerRow, mergeCount)) {
601                    SectionInfo nextSection = mSections.remove(i + 1);
602
603                    // Remove the next section break
604                    mAdapterItems.remove(nextSection.sectionBreakItem);
605                    int pos = mAdapterItems.indexOf(section.firstAppItem);
606                    // Point the section for these new apps to the merged section
607                    int nextPos = pos + section.numApps;
608                    for (int j = nextPos; j < (nextPos + nextSection.numApps); j++) {
609                        AdapterItem item = mAdapterItems.get(j);
610                        item.sectionInfo = section;
611                        item.sectionAppIndex += section.numApps;
612                    }
613
614                    // Update the following adapter items of the removed section item
615                    pos = mAdapterItems.indexOf(nextSection.firstAppItem);
616                    for (int j = pos; j < mAdapterItems.size(); j++) {
617                        AdapterItem item = mAdapterItems.get(j);
618                        item.position--;
619                    }
620                    section.numApps += nextSection.numApps;
621                    sectionAppCount += nextSection.numApps;
622
623                    if (DEBUG) {
624                        Log.d(TAG, "Merging: " + nextSection.firstAppItem.sectionName +
625                                " to " + section.firstAppItem.sectionName +
626                                " mergedNumRows: " + (sectionAppCount / mNumAppsPerRow));
627                    }
628                    mergeCount++;
629                }
630            }
631        }
632    }
633
634    /**
635     * Returns the cached section name for the given title, recomputing and updating the cache if
636     * the title has no cached section name.
637     */
638    private String getAndUpdateCachedSectionName(CharSequence title) {
639        String sectionName = mCachedSectionNames.get(title);
640        if (sectionName == null) {
641            sectionName = mIndexer.computeSectionName(title);
642            mCachedSectionNames.put(title, sectionName);
643        }
644        return sectionName;
645    }
646}
647