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.LauncherSettings.Favorites.CONTAINER_DESKTOP; 24 import static com.android.launcher3.LauncherSettings.Favorites.CONTAINER_HOTSEAT; 25 import static com.android.launcher3.logger.LauncherAtom.Attribute.EMPTY_LABEL; 26 import static com.android.launcher3.logger.LauncherAtom.Attribute.MANUAL_LABEL; 27 import static com.android.launcher3.logger.LauncherAtom.Attribute.SUGGESTED_LABEL; 28 import static com.android.launcher3.userevent.LauncherLogProto.Target.FromFolderLabelState.FROM_CUSTOM; 29 import static com.android.launcher3.userevent.LauncherLogProto.Target.FromFolderLabelState.FROM_EMPTY; 30 import static com.android.launcher3.userevent.LauncherLogProto.Target.FromFolderLabelState.FROM_FOLDER_LABEL_STATE_UNSPECIFIED; 31 import static com.android.launcher3.userevent.LauncherLogProto.Target.FromFolderLabelState.FROM_SUGGESTED; 32 33 import android.os.Process; 34 35 import androidx.annotation.Nullable; 36 37 import com.android.launcher3.LauncherSettings; 38 import com.android.launcher3.Utilities; 39 import com.android.launcher3.config.FeatureFlags; 40 import com.android.launcher3.folder.FolderNameInfos; 41 import com.android.launcher3.logger.LauncherAtom; 42 import com.android.launcher3.logger.LauncherAtom.Attribute; 43 import com.android.launcher3.logger.LauncherAtom.FromState; 44 import com.android.launcher3.logger.LauncherAtom.ToState; 45 import com.android.launcher3.model.ModelWriter; 46 import com.android.launcher3.userevent.LauncherLogProto; 47 import com.android.launcher3.userevent.LauncherLogProto.Target; 48 import com.android.launcher3.userevent.LauncherLogProto.Target.FromFolderLabelState; 49 import com.android.launcher3.userevent.LauncherLogProto.Target.ToFolderLabelState; 50 import com.android.launcher3.util.ContentWriter; 51 52 import java.util.ArrayList; 53 import java.util.OptionalInt; 54 import java.util.stream.IntStream; 55 56 57 /** 58 * Represents a folder containing shortcuts or apps. 59 */ 60 public class FolderInfo extends ItemInfo { 61 62 public static final int NO_FLAGS = 0x00000000; 63 64 /** 65 * The folder is locked in sorted mode 66 */ 67 public static final int FLAG_ITEMS_SORTED = 0x00000001; 68 69 /** 70 * It is a work folder 71 */ 72 public static final int FLAG_WORK_FOLDER = 0x00000002; 73 74 /** 75 * The multi-page animation has run for this folder 76 */ 77 public static final int FLAG_MULTI_PAGE_ANIMATION = 0x00000004; 78 79 public static final int FLAG_MANUAL_FOLDER_NAME = 0x00000008; 80 81 /** 82 * Different states of folder label. 83 */ 84 public enum LabelState { 85 // Folder's label is not yet assigned( i.e., title == null). Eligible for auto-labeling. 86 UNLABELED(Attribute.UNLABELED), 87 88 // Folder's label is empty(i.e., title == ""). Not eligible for auto-labeling. 89 EMPTY(EMPTY_LABEL), 90 91 // Folder's label is one of the non-empty suggested values. 92 SUGGESTED(SUGGESTED_LABEL), 93 94 // Folder's label is non-empty, manually entered by the user 95 // and different from any of suggested values. 96 MANUAL(MANUAL_LABEL); 97 98 private final LauncherAtom.Attribute mLogAttribute; 99 LabelState(Attribute logAttribute)100 LabelState(Attribute logAttribute) { 101 this.mLogAttribute = logAttribute; 102 } 103 } 104 105 public static final String EXTRA_FOLDER_SUGGESTIONS = "suggest"; 106 107 public int options; 108 109 public FolderNameInfos suggestedFolderNames; 110 111 /** 112 * The apps and shortcuts 113 */ 114 public ArrayList<WorkspaceItemInfo> contents = new ArrayList<>(); 115 116 private ArrayList<FolderListener> mListeners = new ArrayList<>(); 117 FolderInfo()118 public FolderInfo() { 119 itemType = LauncherSettings.Favorites.ITEM_TYPE_FOLDER; 120 user = Process.myUserHandle(); 121 } 122 123 /** 124 * Add an app or shortcut 125 * 126 * @param item 127 */ add(WorkspaceItemInfo item, boolean animate)128 public void add(WorkspaceItemInfo item, boolean animate) { 129 add(item, contents.size(), animate); 130 } 131 132 /** 133 * Add an app or shortcut for a specified rank. 134 */ add(WorkspaceItemInfo item, int rank, boolean animate)135 public void add(WorkspaceItemInfo item, int rank, boolean animate) { 136 rank = Utilities.boundToRange(rank, 0, contents.size()); 137 contents.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(WorkspaceItemInfo item, boolean animate)149 public void remove(WorkspaceItemInfo item, boolean animate) { 150 contents.remove(item); 151 for (int i = 0; i < mListeners.size(); i++) { 152 mListeners.get(i).onRemove(item); 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 public void onAdd(WorkspaceItemInfo item, int rank); onRemove(WorkspaceItemInfo item)180 public void onRemove(WorkspaceItemInfo item); onItemsChanged(boolean animate)181 public 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 return getDefaultItemInfoBuilder() 213 .setFolderIcon(LauncherAtom.FolderIcon.newBuilder().setCardinality(contents.size())) 214 .setRank(rank) 215 .setAttribute(getLabelState().mLogAttribute) 216 .setContainerInfo(getContainerInfo()) 217 .build(); 218 } 219 220 @Override setTitle(@ullable CharSequence title, ModelWriter modelWriter)221 public void setTitle(@Nullable CharSequence title, ModelWriter modelWriter) { 222 // Updating label from null to empty is considered as false touch. 223 // Retaining null title(ie., UNLABELED state) allows auto-labeling when new items added. 224 if (isEmpty(title) && this.title == null) { 225 return; 226 } 227 228 // Updating title to same value does not change any states. 229 if (title != null && title.equals(this.title)) { 230 return; 231 } 232 233 this.title = title; 234 LabelState newLabelState = 235 title == null ? LabelState.UNLABELED 236 : title.length() == 0 ? LabelState.EMPTY : 237 getAcceptedSuggestionIndex().isPresent() ? LabelState.SUGGESTED 238 : LabelState.MANUAL; 239 240 if (newLabelState.equals(LabelState.MANUAL)) { 241 options |= FLAG_MANUAL_FOLDER_NAME; 242 } else { 243 options &= ~FLAG_MANUAL_FOLDER_NAME; 244 } 245 if (modelWriter != null) { 246 modelWriter.updateItemInDatabase(this); 247 } 248 } 249 250 /** 251 * Returns current state of the current folder label. 252 */ getLabelState()253 public LabelState getLabelState() { 254 return title == null ? LabelState.UNLABELED 255 : title.length() == 0 ? LabelState.EMPTY : 256 hasOption(FLAG_MANUAL_FOLDER_NAME) ? LabelState.MANUAL 257 : LabelState.SUGGESTED; 258 } 259 260 @Override makeShallowCopy()261 public ItemInfo makeShallowCopy() { 262 FolderInfo folderInfo = new FolderInfo(); 263 folderInfo.copyFrom(this); 264 folderInfo.contents = this.contents; 265 return folderInfo; 266 } 267 268 /** 269 * Returns {@link LauncherAtom.FolderIcon} wrapped as {@link LauncherAtom.ItemInfo} for logging. 270 */ 271 @Override buildProto()272 public LauncherAtom.ItemInfo buildProto() { 273 return buildProto(null); 274 } 275 276 /** 277 * Returns index of the accepted suggestion. 278 */ getAcceptedSuggestionIndex()279 public OptionalInt getAcceptedSuggestionIndex() { 280 String newLabel = checkNotNull(title, 281 "Expected valid folder label, but found null").toString(); 282 if (suggestedFolderNames == null || !suggestedFolderNames.hasSuggestions()) { 283 return OptionalInt.empty(); 284 } 285 CharSequence[] labels = suggestedFolderNames.getLabels(); 286 return IntStream.range(0, labels.length) 287 .filter(index -> !isEmpty(labels[index]) 288 && newLabel.equalsIgnoreCase( 289 labels[index].toString())) 290 .sequential() 291 .findFirst(); 292 } 293 294 /** 295 * Returns {@link FromState} based on current {@link #title}. 296 */ getFromLabelState()297 public LauncherAtom.FromState getFromLabelState() { 298 switch (getLabelState()){ 299 case EMPTY: 300 return LauncherAtom.FromState.FROM_EMPTY; 301 case MANUAL: 302 return LauncherAtom.FromState.FROM_CUSTOM; 303 case SUGGESTED: 304 return LauncherAtom.FromState.FROM_SUGGESTED; 305 case UNLABELED: 306 default: 307 return LauncherAtom.FromState.FROM_STATE_UNSPECIFIED; 308 } 309 } 310 311 /** 312 * Returns {@link ToState} based on current {@link #title}. 313 */ getToLabelState()314 public LauncherAtom.ToState getToLabelState() { 315 if (title == null) { 316 return LauncherAtom.ToState.TO_STATE_UNSPECIFIED; 317 } 318 319 if (!FeatureFlags.FOLDER_NAME_SUGGEST.get()) { 320 return title.length() > 0 321 ? LauncherAtom.ToState.TO_CUSTOM_WITH_SUGGESTIONS_DISABLED 322 : LauncherAtom.ToState.TO_EMPTY_WITH_SUGGESTIONS_DISABLED; 323 } 324 325 // TODO: if suggestedFolderNames is null then it infrastructure issue, not 326 // ranking issue. We should log these appropriately. 327 if (suggestedFolderNames == null || !suggestedFolderNames.hasSuggestions()) { 328 return title.length() > 0 329 ? LauncherAtom.ToState.TO_CUSTOM_WITH_EMPTY_SUGGESTIONS 330 : LauncherAtom.ToState.TO_EMPTY_WITH_EMPTY_SUGGESTIONS; 331 } 332 333 boolean hasValidPrimary = suggestedFolderNames != null && suggestedFolderNames.hasPrimary(); 334 if (title.length() == 0) { 335 return hasValidPrimary ? LauncherAtom.ToState.TO_EMPTY_WITH_VALID_PRIMARY 336 : LauncherAtom.ToState.TO_EMPTY_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY; 337 } 338 339 OptionalInt accepted_suggestion_index = getAcceptedSuggestionIndex(); 340 if (!accepted_suggestion_index.isPresent()) { 341 return hasValidPrimary ? LauncherAtom.ToState.TO_CUSTOM_WITH_VALID_PRIMARY 342 : LauncherAtom.ToState.TO_CUSTOM_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY; 343 } 344 345 switch (accepted_suggestion_index.getAsInt()) { 346 case 0: 347 return LauncherAtom.ToState.TO_SUGGESTION0; 348 case 1: 349 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION1_WITH_VALID_PRIMARY 350 : LauncherAtom.ToState.TO_SUGGESTION1_WITH_EMPTY_PRIMARY; 351 case 2: 352 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION2_WITH_VALID_PRIMARY 353 : LauncherAtom.ToState.TO_SUGGESTION2_WITH_EMPTY_PRIMARY; 354 case 3: 355 return hasValidPrimary ? LauncherAtom.ToState.TO_SUGGESTION3_WITH_VALID_PRIMARY 356 : LauncherAtom.ToState.TO_SUGGESTION3_WITH_EMPTY_PRIMARY; 357 default: 358 // fall through 359 } 360 return LauncherAtom.ToState.TO_STATE_UNSPECIFIED; 361 } 362 363 /** 364 * Returns {@link LauncherLogProto.LauncherEvent} to log current folder label info. 365 * 366 * @deprecated This method is used only for validation purpose and soon will be removed. 367 */ 368 @Deprecated getFolderLabelStateLauncherEvent(FromState fromState, ToState toState)369 public LauncherLogProto.LauncherEvent getFolderLabelStateLauncherEvent(FromState fromState, 370 ToState toState) { 371 return LauncherLogProto.LauncherEvent.newBuilder() 372 .setAction(LauncherLogProto.Action 373 .newBuilder() 374 .setType(LauncherLogProto.Action.Type.SOFT_KEYBOARD)) 375 .addSrcTarget(Target 376 .newBuilder() 377 .setType(Target.Type.ITEM) 378 .setItemType(LauncherLogProto.ItemType.EDITTEXT) 379 .setFromFolderLabelState(convertFolderLabelState(fromState)) 380 .setToFolderLabelState(convertFolderLabelState(toState))) 381 .addSrcTarget(Target.newBuilder() 382 .setType(Target.Type.CONTAINER) 383 .setContainerType(LauncherLogProto.ContainerType.FOLDER) 384 .setPageIndex(screenId) 385 .setGridX(cellX) 386 .setGridY(cellY) 387 .setCardinality(contents.size())) 388 .addSrcTarget(newParentContainerTarget()) 389 .build(); 390 } 391 392 /** 393 * @deprecated This method is used only for validation purpose and soon will be removed. 394 */ 395 @Deprecated newParentContainerTarget()396 private Target.Builder newParentContainerTarget() { 397 Target.Builder builder = Target.newBuilder().setType(Target.Type.CONTAINER); 398 switch (container) { 399 case CONTAINER_HOTSEAT: 400 return builder.setContainerType(LauncherLogProto.ContainerType.HOTSEAT); 401 case CONTAINER_DESKTOP: 402 return builder.setContainerType(LauncherLogProto.ContainerType.WORKSPACE); 403 default: 404 throw new AssertionError(String 405 .format("Expected container to be either %s or %s but found %s.", 406 CONTAINER_HOTSEAT, 407 CONTAINER_DESKTOP, 408 container)); 409 } 410 } 411 412 /** 413 * @deprecated This method is used only for validation purpose and soon will be removed. 414 */ 415 @Deprecated convertFolderLabelState(FromState fromState)416 private static FromFolderLabelState convertFolderLabelState(FromState fromState) { 417 switch (fromState) { 418 case FROM_EMPTY: 419 return FROM_EMPTY; 420 case FROM_SUGGESTED: 421 return FROM_SUGGESTED; 422 case FROM_CUSTOM: 423 return FROM_CUSTOM; 424 default: 425 return FROM_FOLDER_LABEL_STATE_UNSPECIFIED; 426 } 427 } 428 429 /** 430 * @deprecated This method is used only for validation purpose and soon will be removed. 431 */ 432 @Deprecated convertFolderLabelState(ToState toState)433 private static ToFolderLabelState convertFolderLabelState(ToState toState) { 434 switch (toState) { 435 case UNCHANGED: 436 return ToFolderLabelState.UNCHANGED; 437 case TO_SUGGESTION0: 438 return ToFolderLabelState.TO_SUGGESTION0_WITH_VALID_PRIMARY; 439 case TO_SUGGESTION1_WITH_VALID_PRIMARY: 440 return ToFolderLabelState.TO_SUGGESTION1_WITH_VALID_PRIMARY; 441 case TO_SUGGESTION1_WITH_EMPTY_PRIMARY: 442 return ToFolderLabelState.TO_SUGGESTION1_WITH_EMPTY_PRIMARY; 443 case TO_SUGGESTION2_WITH_VALID_PRIMARY: 444 return ToFolderLabelState.TO_SUGGESTION2_WITH_VALID_PRIMARY; 445 case TO_SUGGESTION2_WITH_EMPTY_PRIMARY: 446 return ToFolderLabelState.TO_SUGGESTION2_WITH_EMPTY_PRIMARY; 447 case TO_SUGGESTION3_WITH_VALID_PRIMARY: 448 return ToFolderLabelState.TO_SUGGESTION3_WITH_VALID_PRIMARY; 449 case TO_SUGGESTION3_WITH_EMPTY_PRIMARY: 450 return ToFolderLabelState.TO_SUGGESTION3_WITH_EMPTY_PRIMARY; 451 case TO_EMPTY_WITH_VALID_PRIMARY: 452 return ToFolderLabelState.TO_EMPTY_WITH_VALID_PRIMARY; 453 case TO_EMPTY_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY: 454 return ToFolderLabelState.TO_EMPTY_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY; 455 case TO_EMPTY_WITH_EMPTY_SUGGESTIONS: 456 return ToFolderLabelState.TO_EMPTY_WITH_EMPTY_SUGGESTIONS; 457 case TO_EMPTY_WITH_SUGGESTIONS_DISABLED: 458 return ToFolderLabelState.TO_EMPTY_WITH_SUGGESTIONS_DISABLED; 459 case TO_CUSTOM_WITH_VALID_PRIMARY: 460 return ToFolderLabelState.TO_CUSTOM_WITH_VALID_PRIMARY; 461 case TO_CUSTOM_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY: 462 return ToFolderLabelState.TO_CUSTOM_WITH_VALID_SUGGESTIONS_AND_EMPTY_PRIMARY; 463 case TO_CUSTOM_WITH_EMPTY_SUGGESTIONS: 464 return ToFolderLabelState.TO_CUSTOM_WITH_EMPTY_SUGGESTIONS; 465 case TO_CUSTOM_WITH_SUGGESTIONS_DISABLED: 466 return ToFolderLabelState.TO_CUSTOM_WITH_SUGGESTIONS_DISABLED; 467 default: 468 return ToFolderLabelState.TO_FOLDER_LABEL_STATE_UNSPECIFIED; 469 } 470 } 471 } 472