1 /*
2  * Copyright (C) 2008 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 
17 package com.android.launcher3.model.data;
18 
19 import static android.text.TextUtils.isEmpty;
20 
21 import static androidx.core.util.Preconditions.checkNotNull;
22 
23 import static com.android.launcher3.logger.LauncherAtom.Attribute.EMPTY_LABEL;
24 import static com.android.launcher3.logger.LauncherAtom.Attribute.MANUAL_LABEL;
25 import static com.android.launcher3.logger.LauncherAtom.Attribute.SUGGESTED_LABEL;
26 
27 import androidx.annotation.NonNull;
28 import androidx.annotation.Nullable;
29 
30 import com.android.launcher3.LauncherSettings;
31 import com.android.launcher3.Utilities;
32 import com.android.launcher3.folder.Folder;
33 import com.android.launcher3.folder.FolderNameInfos;
34 import com.android.launcher3.logger.LauncherAtom;
35 import com.android.launcher3.logger.LauncherAtom.Attribute;
36 import com.android.launcher3.logger.LauncherAtom.FolderIcon;
37 import com.android.launcher3.logger.LauncherAtom.FromState;
38 import com.android.launcher3.logger.LauncherAtom.ToState;
39 import com.android.launcher3.model.ModelWriter;
40 import com.android.launcher3.util.ContentWriter;
41 
42 import java.util.ArrayList;
43 import java.util.Collections;
44 import java.util.List;
45 import java.util.OptionalInt;
46 import java.util.stream.IntStream;
47 
48 /**
49  * Represents a folder containing shortcuts or apps.
50  */
51 public class FolderInfo extends CollectionInfo {
52 
53     public static final int NO_FLAGS = 0x00000000;
54 
55     /**
56      * The folder is locked in sorted mode
57      */
58     public static final int FLAG_ITEMS_SORTED = 0x00000001;
59 
60     /**
61      * It is a work folder
62      */
63     public static final int FLAG_WORK_FOLDER = 0x00000002;
64 
65     /**
66      * The multi-page animation has run for this folder
67      */
68     public static final int FLAG_MULTI_PAGE_ANIMATION = 0x00000004;
69 
70     public static final int FLAG_MANUAL_FOLDER_NAME = 0x00000008;
71 
72     /**
73      * Different states of folder label.
74      */
75     public enum LabelState {
76         // Folder's label is not yet assigned( i.e., title == null). Eligible for auto-labeling.
77         UNLABELED(Attribute.UNLABELED),
78 
79         // Folder's label is empty(i.e., title == ""). Not eligible for auto-labeling.
80         EMPTY(EMPTY_LABEL),
81 
82         // Folder's label is one of the non-empty suggested values.
83         SUGGESTED(SUGGESTED_LABEL),
84 
85         // Folder's label is non-empty, manually entered by the user
86         // and different from any of suggested values.
87         MANUAL(MANUAL_LABEL);
88 
89         private final LauncherAtom.Attribute mLogAttribute;
90 
LabelState(Attribute logAttribute)91         LabelState(Attribute logAttribute) {
92             this.mLogAttribute = logAttribute;
93         }
94     }
95 
96     public static final String EXTRA_FOLDER_SUGGESTIONS = "suggest";
97 
98     public int options;
99 
100     public FolderNameInfos suggestedFolderNames;
101 
102     /**
103      * The apps and shortcuts
104      */
105     private final ArrayList<ItemInfo> contents = new ArrayList<>();
106 
107     private ArrayList<FolderListener> mListeners = new ArrayList<>();
108 
FolderInfo()109     public FolderInfo() {
110         itemType = LauncherSettings.Favorites.ITEM_TYPE_FOLDER;
111     }
112 
113     /** Adds a app or shortcut to the contents ArrayList without animation. */
114     @Override
add(@onNull ItemInfo item)115     public void add(@NonNull ItemInfo item) {
116         add(item, false /* animate */);
117     }
118 
119     /**
120      * Add an app or shortcut
121      *
122      * @param item
123      */
add(ItemInfo item, boolean animate)124     public void add(ItemInfo item, boolean animate) {
125         add(item, getContents().size(), animate);
126     }
127 
128     /**
129      * Add an app or shortcut for a specified rank.
130      */
add(ItemInfo item, int rank, boolean animate)131     public void add(ItemInfo item, int rank, boolean animate) {
132         if (!Folder.willAccept(item)) {
133             throw new RuntimeException("tried to add an illegal type into a folder");
134         }
135 
136         rank = Utilities.boundToRange(rank, 0, getContents().size());
137         getContents().add(rank, item);
138         for (int i = 0; i < mListeners.size(); i++) {
139             mListeners.get(i).onAdd(item, rank);
140         }
141         itemsChanged(animate);
142     }
143 
144     /**
145      * Remove an app or shortcut. Does not change the DB.
146      *
147      * @param item
148      */
remove(ItemInfo item, boolean animate)149     public void remove(ItemInfo item, boolean animate) {
150         removeAll(Collections.singletonList(item), animate);
151     }
152 
153     /**
154      * Remove all matching app or shortcut. Does not change the DB.
155      */
removeAll(List<ItemInfo> items, boolean animate)156     public void removeAll(List<ItemInfo> items, boolean animate) {
157         contents.removeAll(items);
158         for (int i = 0; i < mListeners.size(); i++) {
159             mListeners.get(i).onRemove(items);
160         }
161         itemsChanged(animate);
162     }
163 
164     /**
165      * Returns the folder's contents as an ArrayList of {@link ItemInfo}. Includes
166      * {@link WorkspaceItemInfo} and {@link AppPairInfo}s.
167      */
168     @NonNull
169     @Override
getContents()170     public ArrayList<ItemInfo> getContents() {
171         return contents;
172     }
173 
174     /**
175      * Returns the folder's contents as an ArrayList of {@link WorkspaceItemInfo}. Note: Does not
176      * return any {@link AppPairInfo}s contained in the folder, instead collects *their* contents
177      * and adds them to the ArrayList.
178      */
179     @Override
getAppContents()180     public ArrayList<WorkspaceItemInfo> getAppContents()  {
181         ArrayList<WorkspaceItemInfo> workspaceItemInfos = new ArrayList<>();
182         for (ItemInfo item : contents) {
183             if (item instanceof WorkspaceItemInfo wii) {
184                 workspaceItemInfos.add(wii);
185             } else if (item instanceof AppPairInfo api) {
186                 workspaceItemInfos.addAll(api.getAppContents());
187             }
188         }
189         return workspaceItemInfos;
190     }
191 
192     @Override
onAddToDatabase(@onNull ContentWriter writer)193     public void onAddToDatabase(@NonNull ContentWriter writer) {
194         super.onAddToDatabase(writer);
195         writer.put(LauncherSettings.Favorites.OPTIONS, options);
196     }
197 
addListener(FolderListener listener)198     public void addListener(FolderListener listener) {
199         mListeners.add(listener);
200     }
201 
removeListener(FolderListener listener)202     public void removeListener(FolderListener listener) {
203         mListeners.remove(listener);
204     }
205 
itemsChanged(boolean animate)206     public void itemsChanged(boolean animate) {
207         for (int i = 0; i < mListeners.size(); i++) {
208             mListeners.get(i).onItemsChanged(animate);
209         }
210     }
211 
212     public interface FolderListener {
onAdd(ItemInfo item, int rank)213         void onAdd(ItemInfo item, int rank);
onRemove(List<ItemInfo> item)214         void onRemove(List<ItemInfo> item);
onItemsChanged(boolean animate)215         void onItemsChanged(boolean animate);
onTitleChanged(CharSequence title)216         void onTitleChanged(CharSequence title);
217 
218     }
219 
hasOption(int optionFlag)220     public boolean hasOption(int optionFlag) {
221         return (options & optionFlag) != 0;
222     }
223 
224     /**
225      * @param option flag to set or clear
226      * @param isEnabled whether to set or clear the flag
227      * @param writer if not null, save changes to the db.
228      */
setOption(int option, boolean isEnabled, ModelWriter writer)229     public void setOption(int option, boolean isEnabled, ModelWriter writer) {
230         int oldOptions = options;
231         if (isEnabled) {
232             options |= option;
233         } else {
234             options &= ~option;
235         }
236         if (writer != null && oldOptions != options) {
237             writer.updateItemInDatabase(this);
238         }
239     }
240 
241     @Override
dumpProperties()242     protected String dumpProperties() {
243         return String.format("%s; labelState=%s", super.dumpProperties(), getLabelState());
244     }
245 
246     @NonNull
247     @Override
buildProto(@ullable CollectionInfo cInfo)248     public LauncherAtom.ItemInfo buildProto(@Nullable CollectionInfo cInfo) {
249         FolderIcon.Builder folderIcon = FolderIcon.newBuilder()
250                 .setCardinality(getContents().size());
251         if (LabelState.SUGGESTED.equals(getLabelState())) {
252             folderIcon.setLabelInfo(title.toString());
253         }
254         return getDefaultItemInfoBuilder()
255                 .setFolderIcon(folderIcon)
256                 .setRank(rank)
257                 .addItemAttributes(getLabelState().mLogAttribute)
258                 .setContainerInfo(getContainerInfo())
259                 .build();
260     }
261 
262     @Override
setTitle(@ullable CharSequence title, ModelWriter modelWriter)263     public void setTitle(@Nullable CharSequence title, ModelWriter modelWriter) {
264         // Updating label from null to empty is considered as false touch.
265         // Retaining null title(ie., UNLABELED state) allows auto-labeling when new items added.
266         if (isEmpty(title) && this.title == null) {
267             return;
268         }
269 
270         // Updating title to same value does not change any states.
271         if (title != null && title.equals(this.title)) {
272             return;
273         }
274 
275         this.title = title;
276         LabelState newLabelState =
277                 title == null ? LabelState.UNLABELED
278                         : title.length() == 0 ? LabelState.EMPTY :
279                                 getAcceptedSuggestionIndex().isPresent() ? LabelState.SUGGESTED
280                                         : LabelState.MANUAL;
281 
282         if (newLabelState.equals(LabelState.MANUAL)) {
283             options |= FLAG_MANUAL_FOLDER_NAME;
284         } else {
285             options &= ~FLAG_MANUAL_FOLDER_NAME;
286         }
287         if (modelWriter != null) {
288             modelWriter.updateItemInDatabase(this);
289         }
290 
291         for (int i = 0; i < mListeners.size(); i++) {
292             mListeners.get(i).onTitleChanged(title);
293         }
294     }
295 
296     /**
297      * Returns current state of the current folder label.
298      */
getLabelState()299     public LabelState getLabelState() {
300         return title == null ? LabelState.UNLABELED
301                 : title.length() == 0 ? LabelState.EMPTY :
302                         hasOption(FLAG_MANUAL_FOLDER_NAME) ? LabelState.MANUAL
303                                 : LabelState.SUGGESTED;
304     }
305 
306     @NonNull
307     @Override
makeShallowCopy()308     public ItemInfo makeShallowCopy() {
309         FolderInfo folderInfo = new FolderInfo();
310         folderInfo.copyFrom(this);
311         return folderInfo;
312     }
313 
314     @Override
copyFrom(@onNull ItemInfo info)315     public void copyFrom(@NonNull ItemInfo info) {
316         super.copyFrom(info);
317         if (info instanceof FolderInfo fi) {
318             contents.addAll(fi.getContents());
319         }
320     }
321 
322     /**
323      * Returns index of the accepted suggestion.
324      */
getAcceptedSuggestionIndex()325     public OptionalInt getAcceptedSuggestionIndex() {
326         String newLabel = checkNotNull(title,
327                 "Expected valid folder label, but found null").toString();
328         if (suggestedFolderNames == null || !suggestedFolderNames.hasSuggestions()) {
329             return OptionalInt.empty();
330         }
331         CharSequence[] labels = suggestedFolderNames.getLabels();
332         return IntStream.range(0, labels.length)
333                 .filter(index -> !isEmpty(labels[index])
334                         && newLabel.equalsIgnoreCase(
335                         labels[index].toString()))
336                 .sequential()
337                 .findFirst();
338     }
339 
340     /**
341      * Returns {@link FromState} based on current {@link #title}.
342      */
getFromLabelState()343     public LauncherAtom.FromState getFromLabelState() {
344         switch (getLabelState()){
345             case EMPTY:
346                 return LauncherAtom.FromState.FROM_EMPTY;
347             case MANUAL:
348                 return LauncherAtom.FromState.FROM_CUSTOM;
349             case SUGGESTED:
350                 return LauncherAtom.FromState.FROM_SUGGESTED;
351             case UNLABELED:
352             default:
353                 return LauncherAtom.FromState.FROM_STATE_UNSPECIFIED;
354         }
355     }
356 
357     /**
358      * Returns {@link ToState} based on current {@link #title}.
359      */
getToLabelState()360     public LauncherAtom.ToState getToLabelState() {
361         if (title == null) {
362             return LauncherAtom.ToState.TO_STATE_UNSPECIFIED;
363         }
364 
365         // TODO: if suggestedFolderNames is null then it infrastructure issue, not
366         // ranking issue. We should log these appropriately.
367         if (suggestedFolderNames == null || !suggestedFolderNames.hasSuggestions()) {
368             return title.length() > 0
369                     ? LauncherAtom.ToState.TO_CUSTOM_WITH_EMPTY_SUGGESTIONS
370                     : LauncherAtom.ToState.TO_EMPTY_WITH_EMPTY_SUGGESTIONS;
371         }
372 
373         boolean hasValidPrimary = suggestedFolderNames != null && suggestedFolderNames.hasPrimary();
374         if (title.length() == 0) {
375             return hasValidPrimary ? LauncherAtom.ToState.TO_EMPTY_WITH_VALID_PRIMARY
376                     : LauncherAtom.ToState.TO_EMPTY_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY;
377         }
378 
379         OptionalInt accepted_suggestion_index = getAcceptedSuggestionIndex();
380         if (!accepted_suggestion_index.isPresent()) {
381             return hasValidPrimary ? LauncherAtom.ToState.TO_CUSTOM_WITH_VALID_PRIMARY
382                     : LauncherAtom.ToState.TO_CUSTOM_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY;
383         }
384 
385         switch (accepted_suggestion_index.getAsInt()) {
386             case 0:
387                 return LauncherAtom.ToState.TO_SUGGESTION0;
388             case 1:
389                 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION1_WITH_VALID_PRIMARY
390                         : LauncherAtom.ToState.TO_SUGGESTION1_WITH_EMPTY_PRIMARY;
391             case 2:
392                 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION2_WITH_VALID_PRIMARY
393                         : LauncherAtom.ToState.TO_SUGGESTION2_WITH_EMPTY_PRIMARY;
394             case 3:
395                 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION3_WITH_VALID_PRIMARY
396                         : LauncherAtom.ToState.TO_SUGGESTION3_WITH_EMPTY_PRIMARY;
397             default:
398                 // fall through
399         }
400         return LauncherAtom.ToState.TO_STATE_UNSPECIFIED;
401     }
402 }
403