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