1 /* 2 * Copyright (C) 2020 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 package com.android.launcher3.hybridhotseat; 17 18 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_HOTSEAT_EDU_ONLY_TIP; 19 20 import android.content.Intent; 21 import android.graphics.Rect; 22 import android.util.Log; 23 import android.view.Gravity; 24 import android.view.View; 25 26 import com.android.launcher3.BubbleTextView; 27 import com.android.launcher3.CellLayout; 28 import com.android.launcher3.Hotseat; 29 import com.android.launcher3.InvariantDeviceProfile; 30 import com.android.launcher3.Launcher; 31 import com.android.launcher3.LauncherSettings; 32 import com.android.launcher3.R; 33 import com.android.launcher3.Utilities; 34 import com.android.launcher3.Workspace; 35 import com.android.launcher3.config.FeatureFlags; 36 import com.android.launcher3.model.data.FolderInfo; 37 import com.android.launcher3.model.data.ItemInfo; 38 import com.android.launcher3.model.data.WorkspaceItemInfo; 39 import com.android.launcher3.util.GridOccupancy; 40 import com.android.launcher3.util.IntArray; 41 import com.android.launcher3.views.ArrowTipView; 42 import com.android.launcher3.views.Snackbar; 43 44 import java.util.ArrayDeque; 45 import java.util.ArrayList; 46 import java.util.List; 47 import java.util.stream.IntStream; 48 49 /** 50 * Controller class for managing user onboaridng flow for hybrid hotseat 51 */ 52 public class HotseatEduController { 53 54 private static final String TAG = "HotseatEduController"; 55 56 public static final String SETTINGS_ACTION = 57 "android.settings.ACTION_CONTENT_SUGGESTIONS_SETTINGS"; 58 59 private final Launcher mLauncher; 60 private final Hotseat mHotseat; 61 private List<WorkspaceItemInfo> mPredictedApps; 62 private HotseatEduDialog mActiveDialog; 63 64 private ArrayList<ItemInfo> mNewItems = new ArrayList<>(); 65 private IntArray mNewScreens = null; 66 HotseatEduController(Launcher launcher)67 HotseatEduController(Launcher launcher) { 68 mLauncher = launcher; 69 mHotseat = launcher.getHotseat(); 70 } 71 72 /** 73 * Checks what type of migration should be used and migrates hotseat 74 */ migrate()75 void migrate() { 76 HotseatRestoreHelper.createBackup(mLauncher); 77 if (FeatureFlags.HOTSEAT_MIGRATE_TO_FOLDER.get()) { 78 migrateToFolder(); 79 } else { 80 migrateHotseatWhole(); 81 } 82 Snackbar.show(mLauncher, R.string.hotsaet_tip_prediction_enabled, 83 R.string.hotseat_prediction_settings, null, 84 () -> mLauncher.startActivity(getSettingsIntent())); 85 } 86 87 /** 88 * This migration places all non folder items in the hotseat into a folder and then moves 89 * all folders in the hotseat to a workspace page that has enough empty spots. 90 * 91 * @return pageId that has accepted the items. 92 */ migrateToFolder()93 private int migrateToFolder() { 94 ArrayDeque<FolderInfo> folders = new ArrayDeque<>(); 95 ArrayList<WorkspaceItemInfo> putIntoFolder = new ArrayList<>(); 96 97 //separate folders and items that can get in folders 98 for (int i = 0; i < mLauncher.getDeviceProfile().numShownHotseatIcons; i++) { 99 View view = mHotseat.getChildAt(i, 0); 100 if (view == null) continue; 101 ItemInfo info = (ItemInfo) view.getTag(); 102 if (info.itemType == LauncherSettings.Favorites.ITEM_TYPE_FOLDER) { 103 folders.add((FolderInfo) info); 104 } else if (info instanceof WorkspaceItemInfo && info.container == LauncherSettings 105 .Favorites.CONTAINER_HOTSEAT) { 106 putIntoFolder.add((WorkspaceItemInfo) info); 107 } 108 } 109 110 // create a temp folder and add non folder items to it 111 if (!putIntoFolder.isEmpty()) { 112 ItemInfo firstItem = putIntoFolder.get(0); 113 FolderInfo folderInfo = new FolderInfo(); 114 mLauncher.getModelWriter().addItemToDatabase(folderInfo, firstItem.container, 115 firstItem.screenId, firstItem.cellX, firstItem.cellY); 116 folderInfo.setTitle("", mLauncher.getModelWriter()); 117 folderInfo.contents.addAll(putIntoFolder); 118 for (int i = 0; i < folderInfo.contents.size(); i++) { 119 ItemInfo item = folderInfo.contents.get(i); 120 item.rank = i; 121 mLauncher.getModelWriter().moveItemInDatabase(item, folderInfo.id, 0, 122 item.cellX, item.cellY); 123 } 124 folders.add(folderInfo); 125 } 126 mNewItems.addAll(folders); 127 128 return placeFoldersInWorkspace(folders); 129 } 130 placeFoldersInWorkspace(ArrayDeque<FolderInfo> folders)131 private int placeFoldersInWorkspace(ArrayDeque<FolderInfo> folders) { 132 if (folders.isEmpty()) return 0; 133 134 Workspace workspace = mLauncher.getWorkspace(); 135 InvariantDeviceProfile idp = mLauncher.getDeviceProfile().inv; 136 137 GridOccupancy[] occupancyList = new GridOccupancy[workspace.getChildCount()]; 138 for (int i = 0; i < occupancyList.length; i++) { 139 occupancyList[i] = ((CellLayout) workspace.getChildAt(i)).cloneGridOccupancy(); 140 } 141 //scan every screen to find available spots to place folders 142 int occupancyIndex = 0; 143 int[] itemXY = new int[2]; 144 while (occupancyIndex < occupancyList.length && !folders.isEmpty()) { 145 GridOccupancy occupancy = occupancyList[occupancyIndex]; 146 if (occupancy.findVacantCell(itemXY, 1, 1)) { 147 FolderInfo info = folders.poll(); 148 mLauncher.getModelWriter().moveItemInDatabase(info, 149 LauncherSettings.Favorites.CONTAINER_DESKTOP, 150 workspace.getScreenIdForPageIndex(occupancyIndex), itemXY[0], itemXY[1]); 151 occupancy.markCells(info, true); 152 } else { 153 occupancyIndex++; 154 } 155 } 156 if (folders.isEmpty()) return workspace.getScreenIdForPageIndex(occupancyIndex); 157 int screenId = LauncherSettings.Settings.call(mLauncher.getContentResolver(), 158 LauncherSettings.Settings.METHOD_NEW_SCREEN_ID) 159 .getInt(LauncherSettings.Settings.EXTRA_VALUE); 160 // if all screens are full and we still have folders left, put those on a new page 161 FolderInfo folderInfo; 162 int col = 0; 163 while ((folderInfo = folders.poll()) != null) { 164 mLauncher.getModelWriter().moveItemInDatabase(folderInfo, 165 LauncherSettings.Favorites.CONTAINER_DESKTOP, screenId, col++, 166 idp.numRows - 1); 167 } 168 mNewScreens = IntArray.wrap(screenId); 169 return workspace.getPageCount(); 170 } 171 172 /** 173 * This migration option attempts to move the entire hotseat up to the first workspace that 174 * has space to host items. If no such page is found, it moves items to a new page. 175 * 176 * @return pageId where items are migrated 177 */ migrateHotseatWhole()178 private int migrateHotseatWhole() { 179 Workspace workspace = mLauncher.getWorkspace(); 180 181 int pageId = -1; 182 int toRow = 0; 183 for (int i = 0; i < workspace.getPageCount(); i++) { 184 CellLayout target = workspace.getScreenWithId(workspace.getScreenIdForPageIndex(i)); 185 if (target.makeSpaceForHotseatMigration(true)) { 186 toRow = mLauncher.getDeviceProfile().inv.numRows - 1; 187 pageId = i; 188 break; 189 } 190 } 191 if (pageId == -1) { 192 pageId = LauncherSettings.Settings.call(mLauncher.getContentResolver(), 193 LauncherSettings.Settings.METHOD_NEW_SCREEN_ID) 194 .getInt(LauncherSettings.Settings.EXTRA_VALUE); 195 mNewScreens = IntArray.wrap(pageId); 196 } 197 boolean isPortrait = !mLauncher.getDeviceProfile().isVerticalBarLayout(); 198 int hotseatItemsNum = mLauncher.getDeviceProfile().numShownHotseatIcons; 199 for (int i = 0; i < hotseatItemsNum; i++) { 200 int x = isPortrait ? i : 0; 201 int y = isPortrait ? 0 : hotseatItemsNum - i - 1; 202 View child = mHotseat.getChildAt(x, y); 203 if (child == null || child.getTag() == null) continue; 204 ItemInfo tag = (ItemInfo) child.getTag(); 205 if (tag.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION) continue; 206 mLauncher.getModelWriter().moveItemInDatabase(tag, 207 LauncherSettings.Favorites.CONTAINER_DESKTOP, pageId, i, toRow); 208 mNewItems.add(tag); 209 } 210 return pageId; 211 } 212 moveHotseatItems()213 void moveHotseatItems() { 214 mHotseat.removeAllViewsInLayout(); 215 if (!mNewItems.isEmpty()) { 216 int lastPage = mNewItems.get(mNewItems.size() - 1).screenId; 217 ArrayList<ItemInfo> animated = new ArrayList<>(); 218 ArrayList<ItemInfo> nonAnimated = new ArrayList<>(); 219 220 for (ItemInfo info : mNewItems) { 221 if (info.screenId == lastPage) { 222 animated.add(info); 223 } else { 224 nonAnimated.add(info); 225 } 226 } 227 mLauncher.bindAppsAdded(mNewScreens, nonAnimated, animated); 228 } 229 } 230 finishOnboarding()231 void finishOnboarding() { 232 mLauncher.getModel().onWorkspaceUiChanged(); 233 } 234 showDimissTip()235 void showDimissTip() { 236 if (mHotseat.getShortcutsAndWidgets().getChildCount() 237 < mLauncher.getDeviceProfile().numShownHotseatIcons) { 238 Snackbar.show(mLauncher, R.string.hotseat_tip_gaps_filled, 239 R.string.hotseat_prediction_settings, null, 240 () -> mLauncher.startActivity(getSettingsIntent())); 241 } else { 242 showHotseatArrowTip(true, mLauncher.getString(R.string.hotseat_tip_no_empty_slots)); 243 } 244 } 245 setPredictedApps(List<WorkspaceItemInfo> predictedApps)246 void setPredictedApps(List<WorkspaceItemInfo> predictedApps) { 247 mPredictedApps = predictedApps; 248 } 249 showEdu()250 void showEdu() { 251 int childCount = mHotseat.getShortcutsAndWidgets().getChildCount(); 252 CellLayout cellLayout = mLauncher.getWorkspace().getScreenWithId(Workspace.FIRST_SCREEN_ID); 253 // hotseat is already empty and does not require migration. show edu tip 254 boolean requiresMigration = IntStream.range(0, childCount).anyMatch(i -> { 255 View v = mHotseat.getShortcutsAndWidgets().getChildAt(i); 256 return v != null && v.getTag() != null && ((ItemInfo) v.getTag()).container 257 != LauncherSettings.Favorites.CONTAINER_HOTSEAT_PREDICTION; 258 }); 259 boolean canMigrateToFirstPage = cellLayout.makeSpaceForHotseatMigration(false); 260 if (requiresMigration && canMigrateToFirstPage) { 261 showDialog(); 262 } else { 263 if (showHotseatArrowTip(requiresMigration, mLauncher.getString( 264 requiresMigration ? R.string.hotseat_tip_no_empty_slots 265 : R.string.hotseat_auto_enrolled))) { 266 mLauncher.getStatsLogManager().logger().log(LAUNCHER_HOTSEAT_EDU_ONLY_TIP); 267 } 268 finishOnboarding(); 269 } 270 } 271 272 /** 273 * Finds a child suitable child in hotseat and shows arrow tip pointing at it. 274 * 275 * @param usePinned used to determine target view. If true, will use the first matching pinned 276 * item. Otherwise, will use the first predicted child 277 * @param message String to be shown inside the arrowView 278 * @return whether suitable child was found and tip was shown 279 */ showHotseatArrowTip(boolean usePinned, String message)280 private boolean showHotseatArrowTip(boolean usePinned, String message) { 281 int childCount = mHotseat.getShortcutsAndWidgets().getChildCount(); 282 boolean isPortrait = !mLauncher.getDeviceProfile().isVerticalBarLayout(); 283 284 BubbleTextView tipTargetView = null; 285 for (int i = childCount - 1; i > -1; i--) { 286 int x = isPortrait ? i : 0; 287 int y = isPortrait ? 0 : i; 288 View v = mHotseat.getShortcutsAndWidgets().getChildAt(x, y); 289 if (v instanceof BubbleTextView && v.getTag() instanceof WorkspaceItemInfo) { 290 ItemInfo info = (ItemInfo) v.getTag(); 291 boolean isPinned = info.container == LauncherSettings.Favorites.CONTAINER_HOTSEAT; 292 if (isPinned == usePinned) { 293 tipTargetView = (BubbleTextView) v; 294 break; 295 } 296 } 297 } 298 if (tipTargetView == null) { 299 Log.e(TAG, "Unable to find suitable view for ArrowTip"); 300 return false; 301 } 302 Rect bounds = Utilities.getViewBounds(tipTargetView); 303 new ArrowTipView(mLauncher).show(message, Gravity.END, bounds.centerX(), bounds.top); 304 return true; 305 } 306 showDialog()307 void showDialog() { 308 if (mPredictedApps == null || mPredictedApps.isEmpty()) { 309 return; 310 } 311 if (mActiveDialog != null) { 312 mActiveDialog.handleClose(false); 313 } 314 mActiveDialog = HotseatEduDialog.getDialog(mLauncher); 315 mActiveDialog.setHotseatEduController(this); 316 mActiveDialog.show(mPredictedApps); 317 } 318 getSettingsIntent()319 static Intent getSettingsIntent() { 320 return new Intent(SETTINGS_ACTION).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 321 } 322 } 323