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