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.NonNull; 30 import androidx.annotation.Nullable; 31 32 import com.android.launcher3.LauncherSettings; 33 import com.android.launcher3.Utilities; 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(@onNull ContentWriter writer)158 public void onAddToDatabase(@NonNull 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 @NonNull 211 @Override buildProto(@ullable FolderInfo fInfo)212 public LauncherAtom.ItemInfo buildProto(@Nullable FolderInfo fInfo) { 213 FolderIcon.Builder folderIcon = FolderIcon.newBuilder() 214 .setCardinality(contents.size()); 215 if (LabelState.SUGGESTED.equals(getLabelState())) { 216 folderIcon.setLabelInfo(title.toString()); 217 } 218 return getDefaultItemInfoBuilder() 219 .setFolderIcon(folderIcon) 220 .setRank(rank) 221 .addItemAttributes(getLabelState().mLogAttribute) 222 .setContainerInfo(getContainerInfo()) 223 .build(); 224 } 225 226 @Override setTitle(@ullable CharSequence title, ModelWriter modelWriter)227 public void setTitle(@Nullable CharSequence title, ModelWriter modelWriter) { 228 // Updating label from null to empty is considered as false touch. 229 // Retaining null title(ie., UNLABELED state) allows auto-labeling when new items added. 230 if (isEmpty(title) && this.title == null) { 231 return; 232 } 233 234 // Updating title to same value does not change any states. 235 if (title != null && title.equals(this.title)) { 236 return; 237 } 238 239 this.title = title; 240 LabelState newLabelState = 241 title == null ? LabelState.UNLABELED 242 : title.length() == 0 ? LabelState.EMPTY : 243 getAcceptedSuggestionIndex().isPresent() ? LabelState.SUGGESTED 244 : LabelState.MANUAL; 245 246 if (newLabelState.equals(LabelState.MANUAL)) { 247 options |= FLAG_MANUAL_FOLDER_NAME; 248 } else { 249 options &= ~FLAG_MANUAL_FOLDER_NAME; 250 } 251 if (modelWriter != null) { 252 modelWriter.updateItemInDatabase(this); 253 } 254 } 255 256 /** 257 * Returns current state of the current folder label. 258 */ getLabelState()259 public LabelState getLabelState() { 260 return title == null ? LabelState.UNLABELED 261 : title.length() == 0 ? LabelState.EMPTY : 262 hasOption(FLAG_MANUAL_FOLDER_NAME) ? LabelState.MANUAL 263 : LabelState.SUGGESTED; 264 } 265 266 @NonNull 267 @Override makeShallowCopy()268 public ItemInfo makeShallowCopy() { 269 FolderInfo folderInfo = new FolderInfo(); 270 folderInfo.copyFrom(this); 271 folderInfo.contents = this.contents; 272 return folderInfo; 273 } 274 275 /** 276 * Returns {@link LauncherAtom.FolderIcon} wrapped as {@link LauncherAtom.ItemInfo} for logging. 277 */ 278 @NonNull 279 @Override buildProto()280 public LauncherAtom.ItemInfo buildProto() { 281 return buildProto(null); 282 } 283 284 /** 285 * Returns index of the accepted suggestion. 286 */ getAcceptedSuggestionIndex()287 public OptionalInt getAcceptedSuggestionIndex() { 288 String newLabel = checkNotNull(title, 289 "Expected valid folder label, but found null").toString(); 290 if (suggestedFolderNames == null || !suggestedFolderNames.hasSuggestions()) { 291 return OptionalInt.empty(); 292 } 293 CharSequence[] labels = suggestedFolderNames.getLabels(); 294 return IntStream.range(0, labels.length) 295 .filter(index -> !isEmpty(labels[index]) 296 && newLabel.equalsIgnoreCase( 297 labels[index].toString())) 298 .sequential() 299 .findFirst(); 300 } 301 302 /** 303 * Returns {@link FromState} based on current {@link #title}. 304 */ getFromLabelState()305 public LauncherAtom.FromState getFromLabelState() { 306 switch (getLabelState()){ 307 case EMPTY: 308 return LauncherAtom.FromState.FROM_EMPTY; 309 case MANUAL: 310 return LauncherAtom.FromState.FROM_CUSTOM; 311 case SUGGESTED: 312 return LauncherAtom.FromState.FROM_SUGGESTED; 313 case UNLABELED: 314 default: 315 return LauncherAtom.FromState.FROM_STATE_UNSPECIFIED; 316 } 317 } 318 319 /** 320 * Returns {@link ToState} based on current {@link #title}. 321 */ getToLabelState()322 public LauncherAtom.ToState getToLabelState() { 323 if (title == null) { 324 return LauncherAtom.ToState.TO_STATE_UNSPECIFIED; 325 } 326 327 // TODO: if suggestedFolderNames is null then it infrastructure issue, not 328 // ranking issue. We should log these appropriately. 329 if (suggestedFolderNames == null || !suggestedFolderNames.hasSuggestions()) { 330 return title.length() > 0 331 ? LauncherAtom.ToState.TO_CUSTOM_WITH_EMPTY_SUGGESTIONS 332 : LauncherAtom.ToState.TO_EMPTY_WITH_EMPTY_SUGGESTIONS; 333 } 334 335 boolean hasValidPrimary = suggestedFolderNames != null && suggestedFolderNames.hasPrimary(); 336 if (title.length() == 0) { 337 return hasValidPrimary ? LauncherAtom.ToState.TO_EMPTY_WITH_VALID_PRIMARY 338 : LauncherAtom.ToState.TO_EMPTY_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY; 339 } 340 341 OptionalInt accepted_suggestion_index = getAcceptedSuggestionIndex(); 342 if (!accepted_suggestion_index.isPresent()) { 343 return hasValidPrimary ? LauncherAtom.ToState.TO_CUSTOM_WITH_VALID_PRIMARY 344 : LauncherAtom.ToState.TO_CUSTOM_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY; 345 } 346 347 switch (accepted_suggestion_index.getAsInt()) { 348 case 0: 349 return LauncherAtom.ToState.TO_SUGGESTION0; 350 case 1: 351 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION1_WITH_VALID_PRIMARY 352 : LauncherAtom.ToState.TO_SUGGESTION1_WITH_EMPTY_PRIMARY; 353 case 2: 354 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION2_WITH_VALID_PRIMARY 355 : LauncherAtom.ToState.TO_SUGGESTION2_WITH_EMPTY_PRIMARY; 356 case 3: 357 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION3_WITH_VALID_PRIMARY 358 : LauncherAtom.ToState.TO_SUGGESTION3_WITH_EMPTY_PRIMARY; 359 default: 360 // fall through 361 } 362 return LauncherAtom.ToState.TO_STATE_UNSPECIFIED; 363 } 364 } 365