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