From 68e1af2bc236d88d2ef2fdcbe3890b86268b9fee Mon Sep 17 00:00:00 2001 From: Andras Kloczl Date: Mon, 22 Nov 2021 16:54:54 +0000 Subject: [PATCH] Separate workspace item finding logic Extract the item finding logic from AddWorkspaceItemsTask to a separate class and write tests. Test: AddWorkspaceItemsTaskTest.kt , WorkspaceItemSpaceFinderTest.kt Bug: 199160559 Change-Id: Ie1bc4fcd4f94cd7cb0601c21bbdf273452b9dd1f --- .../model/AddWorkspaceItemsTask.java | 101 +------- .../model/WorkspaceItemSpaceFinder.java | 115 ++++++++ .../model/AbstractWorkspaceModelTest.kt | 162 ++++++++++++ .../model/AddWorkspaceItemsTaskTest.java | 201 -------------- .../model/AddWorkspaceItemsTaskTest.kt | 245 ++++++++++++++++++ .../model/WorkspaceItemSpaceFinderTest.kt | 171 ++++++++++++ .../launcher3/util/KotlinMockitoHelpers.kt | 117 +++++++++ 7 files changed, 824 insertions(+), 288 deletions(-) create mode 100644 src/com/android/launcher3/model/WorkspaceItemSpaceFinder.java create mode 100644 tests/src/com/android/launcher3/model/AbstractWorkspaceModelTest.kt delete mode 100644 tests/src/com/android/launcher3/model/AddWorkspaceItemsTaskTest.java create mode 100644 tests/src/com/android/launcher3/model/AddWorkspaceItemsTaskTest.kt create mode 100644 tests/src/com/android/launcher3/model/WorkspaceItemSpaceFinderTest.kt create mode 100644 tests/src/com/android/launcher3/util/KotlinMockitoHelpers.kt diff --git a/src/com/android/launcher3/model/AddWorkspaceItemsTask.java b/src/com/android/launcher3/model/AddWorkspaceItemsTask.java index 91fb44e84c..ca91296ba6 100644 --- a/src/com/android/launcher3/model/AddWorkspaceItemsTask.java +++ b/src/com/android/launcher3/model/AddWorkspaceItemsTask.java @@ -15,22 +15,17 @@ */ package com.android.launcher3.model; -import static com.android.launcher3.WorkspaceLayoutManager.FIRST_SCREEN_ID; - import android.content.Intent; import android.content.pm.LauncherActivityInfo; import android.content.pm.LauncherApps; import android.content.pm.PackageInstaller.SessionInfo; import android.os.UserHandle; import android.util.Log; -import android.util.LongSparseArray; import android.util.Pair; -import com.android.launcher3.InvariantDeviceProfile; import com.android.launcher3.LauncherAppState; import com.android.launcher3.LauncherModel.CallbackTask; import com.android.launcher3.LauncherSettings; -import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.logging.FileLog; import com.android.launcher3.model.BgDataModel.Callbacks; import com.android.launcher3.model.data.AppInfo; @@ -41,9 +36,7 @@ import com.android.launcher3.model.data.WorkspaceItemInfo; import com.android.launcher3.pm.InstallSessionHelper; import com.android.launcher3.pm.PackageInstallInfo; import com.android.launcher3.testing.TestProtocol; -import com.android.launcher3.util.GridOccupancy; import com.android.launcher3.util.IntArray; -import com.android.launcher3.util.IntSet; import com.android.launcher3.util.PackageManagerHelper; import java.util.ArrayList; @@ -58,11 +51,23 @@ public class AddWorkspaceItemsTask extends BaseModelUpdateTask { private final List> mItemList; + private final WorkspaceItemSpaceFinder mItemSpaceFinder; + /** * @param itemList items to add on the workspace */ public AddWorkspaceItemsTask(List> itemList) { + this(itemList, new WorkspaceItemSpaceFinder()); + } + + /** + * @param itemList items to add on the workspace + * @param itemSpaceFinder inject WorkspaceItemSpaceFinder dependency for testing + */ + public AddWorkspaceItemsTask(List> itemList, + WorkspaceItemSpaceFinder itemSpaceFinder) { mItemList = itemList; + mItemSpaceFinder = itemSpaceFinder; } @Override @@ -74,7 +79,7 @@ public class AddWorkspaceItemsTask extends BaseModelUpdateTask { final ArrayList addedItemsFinal = new ArrayList<>(); final IntArray addedWorkspaceScreensFinal = new IntArray(); - synchronized(dataModel) { + synchronized (dataModel) { IntArray workspaceScreens = dataModel.collectWorkspaceScreens(); List filteredItems = new ArrayList<>(); @@ -117,7 +122,7 @@ public class AddWorkspaceItemsTask extends BaseModelUpdateTask { for (ItemInfo item : filteredItems) { // Find appropriate space for the item. - int[] coords = findSpaceForItem(app, dataModel, workspaceScreens, + int[] coords = mItemSpaceFinder.findSpaceForItem(app, dataModel, workspaceScreens, addedWorkspaceScreensFinal, item.spanX, item.spanY); int screenId = coords[0]; @@ -288,82 +293,4 @@ public class AddWorkspaceItemsTask extends BaseModelUpdateTask { } return false; } - - /** - * Find a position on the screen for the given size or adds a new screen. - * @return screenId and the coordinates for the item in an int array of size 3. - */ - protected int[] findSpaceForItem( LauncherAppState app, BgDataModel dataModel, - IntArray workspaceScreens, IntArray addedWorkspaceScreensFinal, int spanX, int spanY) { - LongSparseArray> screenItems = new LongSparseArray<>(); - - // Use sBgItemsIdMap as all the items are already loaded. - synchronized (dataModel) { - for (ItemInfo info : dataModel.itemsIdMap) { - if (info.container == LauncherSettings.Favorites.CONTAINER_DESKTOP) { - ArrayList items = screenItems.get(info.screenId); - if (items == null) { - items = new ArrayList<>(); - screenItems.put(info.screenId, items); - } - items.add(info); - } - } - } - - // Find appropriate space for the item. - int screenId = 0; - int[] coordinates = new int[2]; - boolean found = false; - - int screenCount = workspaceScreens.size(); - // First check the preferred screen. - IntSet screensToExclude = new IntSet(); - if (FeatureFlags.QSB_ON_FIRST_SCREEN) { - screensToExclude.add(FIRST_SCREEN_ID); - } - - for (int screen = 0; screen < screenCount; screen++) { - screenId = workspaceScreens.get(screen); - if (!screensToExclude.contains(screenId) && findNextAvailableIconSpaceInScreen( - app, screenItems.get(screenId), coordinates, spanX, spanY)) { - // We found a space for it - found = true; - break; - } - } - - if (!found) { - // Still no position found. Add a new screen to the end. - screenId = LauncherSettings.Settings.call(app.getContext().getContentResolver(), - LauncherSettings.Settings.METHOD_NEW_SCREEN_ID) - .getInt(LauncherSettings.Settings.EXTRA_VALUE); - - // Save the screen id for binding in the workspace - workspaceScreens.add(screenId); - addedWorkspaceScreensFinal.add(screenId); - - // If we still can't find an empty space, then God help us all!!! - if (!findNextAvailableIconSpaceInScreen( - app, screenItems.get(screenId), coordinates, spanX, spanY)) { - throw new RuntimeException("Can't find space to add the item"); - } - } - return new int[] {screenId, coordinates[0], coordinates[1]}; - } - - private boolean findNextAvailableIconSpaceInScreen( - LauncherAppState app, ArrayList occupiedPos, - int[] xy, int spanX, int spanY) { - InvariantDeviceProfile profile = app.getInvariantDeviceProfile(); - - GridOccupancy occupied = new GridOccupancy(profile.numColumns, profile.numRows); - if (occupiedPos != null) { - for (ItemInfo r : occupiedPos) { - occupied.markCells(r, true); - } - } - return occupied.findVacantCell(xy, spanX, spanY); - } - } diff --git a/src/com/android/launcher3/model/WorkspaceItemSpaceFinder.java b/src/com/android/launcher3/model/WorkspaceItemSpaceFinder.java new file mode 100644 index 0000000000..93fc6a539f --- /dev/null +++ b/src/com/android/launcher3/model/WorkspaceItemSpaceFinder.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.model; + +import static com.android.launcher3.WorkspaceLayoutManager.FIRST_SCREEN_ID; + +import android.util.LongSparseArray; + +import com.android.launcher3.InvariantDeviceProfile; +import com.android.launcher3.LauncherAppState; +import com.android.launcher3.LauncherSettings; +import com.android.launcher3.config.FeatureFlags; +import com.android.launcher3.model.data.ItemInfo; +import com.android.launcher3.util.GridOccupancy; +import com.android.launcher3.util.IntArray; +import com.android.launcher3.util.IntSet; + +import java.util.ArrayList; + +/** + * Utility class to help find space for new workspace items + */ +public class WorkspaceItemSpaceFinder { + + /** + * Find a position on the screen for the given size or adds a new screen. + * + * @return screenId and the coordinates for the item in an int array of size 3. + */ + public int[] findSpaceForItem(LauncherAppState app, BgDataModel dataModel, + IntArray workspaceScreens, IntArray addedWorkspaceScreensFinal, int spanX, int spanY) { + LongSparseArray> screenItems = new LongSparseArray<>(); + + // Use sBgItemsIdMap as all the items are already loaded. + synchronized (dataModel) { + for (ItemInfo info : dataModel.itemsIdMap) { + if (info.container == LauncherSettings.Favorites.CONTAINER_DESKTOP) { + ArrayList items = screenItems.get(info.screenId); + if (items == null) { + items = new ArrayList<>(); + screenItems.put(info.screenId, items); + } + items.add(info); + } + } + } + + // Find appropriate space for the item. + int screenId = 0; + int[] coordinates = new int[2]; + boolean found = false; + + int screenCount = workspaceScreens.size(); + // First check the preferred screen. + IntSet screensToExclude = new IntSet(); + if (FeatureFlags.QSB_ON_FIRST_SCREEN) { + screensToExclude.add(FIRST_SCREEN_ID); + } + + for (int screen = 0; screen < screenCount; screen++) { + screenId = workspaceScreens.get(screen); + if (!screensToExclude.contains(screenId) && findNextAvailableIconSpaceInScreen( + app, screenItems.get(screenId), coordinates, spanX, spanY)) { + // We found a space for it + found = true; + break; + } + } + + if (!found) { + // Still no position found. Add a new screen to the end. + screenId = LauncherSettings.Settings.call(app.getContext().getContentResolver(), + LauncherSettings.Settings.METHOD_NEW_SCREEN_ID) + .getInt(LauncherSettings.Settings.EXTRA_VALUE); + + // Save the screen id for binding in the workspace + workspaceScreens.add(screenId); + addedWorkspaceScreensFinal.add(screenId); + + // If we still can't find an empty space, then God help us all!!! + if (!findNextAvailableIconSpaceInScreen( + app, screenItems.get(screenId), coordinates, spanX, spanY)) { + throw new RuntimeException("Can't find space to add the item"); + } + } + return new int[]{screenId, coordinates[0], coordinates[1]}; + } + + private boolean findNextAvailableIconSpaceInScreen( + LauncherAppState app, ArrayList occupiedPos, + int[] xy, int spanX, int spanY) { + InvariantDeviceProfile profile = app.getInvariantDeviceProfile(); + + GridOccupancy occupied = new GridOccupancy(profile.numColumns, profile.numRows); + if (occupiedPos != null) { + for (ItemInfo r : occupiedPos) { + occupied.markCells(r, true); + } + } + return occupied.findVacantCell(xy, spanX, spanY); + } +} diff --git a/tests/src/com/android/launcher3/model/AbstractWorkspaceModelTest.kt b/tests/src/com/android/launcher3/model/AbstractWorkspaceModelTest.kt new file mode 100644 index 0000000000..d26381dc2f --- /dev/null +++ b/tests/src/com/android/launcher3/model/AbstractWorkspaceModelTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.model + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import com.android.launcher3.InvariantDeviceProfile +import com.android.launcher3.LauncherAppState +import com.android.launcher3.LauncherSettings +import com.android.launcher3.model.data.WorkspaceItemInfo +import com.android.launcher3.util.ContentWriter +import com.android.launcher3.util.GridOccupancy +import com.android.launcher3.util.IntArray +import com.android.launcher3.util.IntSparseArrayMap +import com.android.launcher3.util.LauncherModelHelper +import java.util.UUID + +/** + * Base class for workspace related tests. + */ +abstract class AbstractWorkspaceModelTest { + companion object { + val emptyScreenSpaces = listOf(Rect(0, 0, 5, 5)) + val fullScreenSpaces = emptyList() + val nonEmptyScreenSpaces = listOf(Rect(1, 2, 3, 4)) + } + + protected lateinit var mTargetContext: Context + protected lateinit var mIdp: InvariantDeviceProfile + protected lateinit var mAppState: LauncherAppState + protected lateinit var mModelHelper: LauncherModelHelper + protected lateinit var mExistingScreens: IntArray + protected lateinit var mNewScreens: IntArray + protected lateinit var mScreenOccupancy: IntSparseArrayMap + + open fun setup() { + mModelHelper = LauncherModelHelper() + mTargetContext = mModelHelper.sandboxContext + mIdp = InvariantDeviceProfile.INSTANCE[mTargetContext] + mIdp.numRows = 5 + mIdp.numColumns = mIdp.numRows + mAppState = LauncherAppState.getInstance(mTargetContext) + mExistingScreens = IntArray() + mScreenOccupancy = IntSparseArrayMap() + mNewScreens = IntArray() + } + + open fun tearDown() { + mModelHelper.destroy() + } + + + /** + * Sets up workspaces with the given screen IDs with some items and a 2x2 space. + */ + fun setupWorkspaces(screenIdsWithItems: List) { + var nextItemId = 1 + screenIdsWithItems.forEach { screenId -> + nextItemId = setupWorkspace(nextItemId, screenId, nonEmptyScreenSpaces) + } + } + + /** + * Sets up the given workspaces with the given spaces, and fills the remaining space with items. + */ + fun setupWorkspacesWithSpaces( + screen0: List? = null, + screen1: List? = null, + screen2: List? = null, + screen3: List? = null, + ) = listOf(screen0, screen1, screen2, screen3) + .let(this::setupWithSpaces) + + private fun setupWithSpaces(workspaceSpaces: List?>) { + var nextItemId = 1 + workspaceSpaces.forEachIndexed { screenId, spaces -> + if (spaces != null) { + nextItemId = setupWorkspace(nextItemId, screenId, spaces) + } + } + } + + private fun setupWorkspace(startId: Int, screenId: Int, spaces: List): Int { + return mModelHelper.executeSimpleTask { dataModel -> + writeWorkspaceWithSpaces(dataModel, startId, screenId, spaces) + } + } + + private fun writeWorkspaceWithSpaces( + bgDataModel: BgDataModel, + itemStartId: Int, + screenId: Int, + spaces: List, + ): Int { + var itemId = itemStartId + val occupancy = GridOccupancy(mIdp.numColumns, mIdp.numRows) + occupancy.markCells(0, 0, mIdp.numColumns, mIdp.numRows, true) + spaces.forEach { spaceRect -> + occupancy.markCells(spaceRect, false) + } + mExistingScreens.add(screenId) + mScreenOccupancy.append(screenId, occupancy) + for (x in 0 until mIdp.numColumns) { + for (y in 0 until mIdp.numRows) { + if (!occupancy.cells[x][y]) { + continue + } + val info = getExistingItem() + info.id = itemId++ + info.screenId = screenId + info.cellX = x + info.cellY = y + info.container = LauncherSettings.Favorites.CONTAINER_DESKTOP + bgDataModel.addItem(mTargetContext, info, false) + val writer = ContentWriter(mTargetContext) + info.writeToValues(writer) + writer.put(LauncherSettings.Favorites._ID, info.id) + mTargetContext.contentResolver.insert( + LauncherSettings.Favorites.CONTENT_URI, + writer.getValues(mTargetContext) + ) + } + } + return itemId + } + + fun getExistingItem() = WorkspaceItemInfo() + .apply { intent = Intent().setComponent(ComponentName("a", "b")) } + + fun getNewItem(): WorkspaceItemInfo { + val itemPackage = UUID.randomUUID().toString() + return WorkspaceItemInfo() + .apply { intent = Intent().setComponent(ComponentName(itemPackage, itemPackage)) } + } +} + +data class NewItemSpace( + val screenId: Int, + val cellX: Int, + val cellY: Int +) { + fun toIntArray() = intArrayOf(screenId, cellX, cellY) + + companion object { + fun fromIntArray(array: kotlin.IntArray) = NewItemSpace(array[0], array[1], array[2]) + } +} \ No newline at end of file diff --git a/tests/src/com/android/launcher3/model/AddWorkspaceItemsTaskTest.java b/tests/src/com/android/launcher3/model/AddWorkspaceItemsTaskTest.java deleted file mode 100644 index 8a4590a388..0000000000 --- a/tests/src/com/android/launcher3/model/AddWorkspaceItemsTaskTest.java +++ /dev/null @@ -1,201 +0,0 @@ -package com.android.launcher3.model; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.graphics.Rect; -import android.util.Pair; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.filters.SmallTest; - -import com.android.launcher3.InvariantDeviceProfile; -import com.android.launcher3.LauncherAppState; -import com.android.launcher3.LauncherSettings; -import com.android.launcher3.LauncherSettings.Favorites; -import com.android.launcher3.model.BgDataModel.Callbacks; -import com.android.launcher3.model.data.ItemInfo; -import com.android.launcher3.model.data.WorkspaceItemInfo; -import com.android.launcher3.util.ContentWriter; -import com.android.launcher3.util.Executors; -import com.android.launcher3.util.GridOccupancy; -import com.android.launcher3.util.IntArray; -import com.android.launcher3.util.IntSparseArrayMap; -import com.android.launcher3.util.LauncherModelHelper; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; - -import java.util.ArrayList; -import java.util.List; - -/** - * Tests for {@link AddWorkspaceItemsTask} - */ -@SmallTest -@RunWith(AndroidJUnit4.class) -public class AddWorkspaceItemsTaskTest { - - private final ComponentName mComponent1 = new ComponentName("a", "b"); - private final ComponentName mComponent2 = new ComponentName("b", "b"); - - private Context mTargetContext; - private InvariantDeviceProfile mIdp; - private LauncherAppState mAppState; - private LauncherModelHelper mModelHelper; - - private IntArray mExistingScreens; - private IntArray mNewScreens; - private IntSparseArrayMap mScreenOccupancy; - - @Before - public void setup() { - mModelHelper = new LauncherModelHelper(); - mTargetContext = mModelHelper.sandboxContext; - mIdp = InvariantDeviceProfile.INSTANCE.get(mTargetContext); - mIdp.numColumns = mIdp.numRows = 5; - mAppState = LauncherAppState.getInstance(mTargetContext); - - mExistingScreens = new IntArray(); - mScreenOccupancy = new IntSparseArrayMap<>(); - mNewScreens = new IntArray(); - } - - @After - public void tearDown() { - mModelHelper.destroy(); - } - - private AddWorkspaceItemsTask newTask(ItemInfo... items) { - List> list = new ArrayList<>(); - for (ItemInfo item : items) { - list.add(Pair.create(item, null)); - } - return new AddWorkspaceItemsTask(list); - } - - @Test - public void testFindSpaceForItem_prefers_second() throws Exception { - // First screen has only one hole of size 1 - int nextId = setupWorkspaceWithHoles(1, 1, new Rect(2, 2, 3, 3)); - - // Second screen has 2 holes of sizes 3x2 and 2x3 - setupWorkspaceWithHoles(nextId, 2, new Rect(2, 0, 5, 2), new Rect(0, 2, 2, 5)); - - int[] spaceFound = newTask().findSpaceForItem( - mAppState, mModelHelper.getBgDataModel(), mExistingScreens, mNewScreens, 1, 1); - assertEquals(1, spaceFound[0]); - assertTrue(mScreenOccupancy.get(spaceFound[0]) - .isRegionVacant(spaceFound[1], spaceFound[2], 1, 1)); - - // Find a larger space - spaceFound = newTask().findSpaceForItem( - mAppState, mModelHelper.getBgDataModel(), mExistingScreens, mNewScreens, 2, 3); - assertEquals(2, spaceFound[0]); - assertTrue(mScreenOccupancy.get(spaceFound[0]) - .isRegionVacant(spaceFound[1], spaceFound[2], 2, 3)); - } - - @Test - public void testFindSpaceForItem_adds_new_screen() throws Exception { - // First screen has 2 holes of sizes 3x2 and 2x3 - setupWorkspaceWithHoles(1, 1, new Rect(2, 0, 5, 2), new Rect(0, 2, 2, 5)); - - IntArray oldScreens = mExistingScreens.clone(); - int[] spaceFound = newTask().findSpaceForItem( - mAppState, mModelHelper.getBgDataModel(), mExistingScreens, mNewScreens, 3, 3); - assertFalse(oldScreens.contains(spaceFound[0])); - assertTrue(mNewScreens.contains(spaceFound[0])); - } - - @Test - public void testAddItem_existing_item_ignored() throws Exception { - WorkspaceItemInfo info = new WorkspaceItemInfo(); - info.intent = new Intent().setComponent(mComponent1); - - // Setup a screen with a hole - setupWorkspaceWithHoles(1, 1, new Rect(2, 2, 3, 3)); - - // Nothing was added - assertTrue(mModelHelper.executeTaskForTest(newTask(info)).isEmpty()); - } - - @Test - public void testAddItem_some_items_added() throws Exception { - Callbacks callbacks = mock(Callbacks.class); - Executors.MAIN_EXECUTOR.submit(() -> mModelHelper.getModel().addCallbacks(callbacks)).get(); - - WorkspaceItemInfo info = new WorkspaceItemInfo(); - info.intent = new Intent().setComponent(mComponent1); - - WorkspaceItemInfo info2 = new WorkspaceItemInfo(); - info2.intent = new Intent().setComponent(mComponent2); - - // Setup a screen with a hole - setupWorkspaceWithHoles(1, 1, new Rect(2, 2, 3, 3)); - - mModelHelper.executeTaskForTest(newTask(info, info2)).get(0).run(); - ArgumentCaptor notAnimated = ArgumentCaptor.forClass(ArrayList.class); - ArgumentCaptor animated = ArgumentCaptor.forClass(ArrayList.class); - - // only info2 should be added because info was already added to the workspace - // in setupWorkspaceWithHoles() - verify(callbacks).bindAppsAdded(any(IntArray.class), notAnimated.capture(), - animated.capture()); - assertTrue(notAnimated.getValue().isEmpty()); - - assertEquals(1, animated.getValue().size()); - assertTrue(animated.getValue().contains(info2)); - } - - private int setupWorkspaceWithHoles(int startId, int screenId, Rect... holes) throws Exception { - return mModelHelper.executeSimpleTask( - model -> writeWorkspaceWithHoles(model, startId, screenId, holes)); - } - - private int writeWorkspaceWithHoles( - BgDataModel bgDataModel, int startId, int screenId, Rect... holes) { - GridOccupancy occupancy = new GridOccupancy(mIdp.numColumns, mIdp.numRows); - occupancy.markCells(0, 0, mIdp.numColumns, mIdp.numRows, true); - for (Rect r : holes) { - occupancy.markCells(r, false); - } - - mExistingScreens.add(screenId); - mScreenOccupancy.append(screenId, occupancy); - - for (int x = 0; x < mIdp.numColumns; x++) { - for (int y = 0; y < mIdp.numRows; y++) { - if (!occupancy.cells[x][y]) { - continue; - } - - WorkspaceItemInfo info = new WorkspaceItemInfo(); - info.intent = new Intent().setComponent(mComponent1); - info.id = startId++; - info.screenId = screenId; - info.cellX = x; - info.cellY = y; - info.container = LauncherSettings.Favorites.CONTAINER_DESKTOP; - bgDataModel.addItem(mTargetContext, info, false); - - ContentWriter writer = new ContentWriter(mTargetContext); - info.writeToValues(writer); - writer.put(Favorites._ID, info.id); - mTargetContext.getContentResolver().insert(Favorites.CONTENT_URI, - writer.getValues(mTargetContext)); - } - } - return startId; - } -} diff --git a/tests/src/com/android/launcher3/model/AddWorkspaceItemsTaskTest.kt b/tests/src/com/android/launcher3/model/AddWorkspaceItemsTaskTest.kt new file mode 100644 index 0000000000..65d938b91a --- /dev/null +++ b/tests/src/com/android/launcher3/model/AddWorkspaceItemsTaskTest.kt @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.model + +import android.util.Pair +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.launcher3.model.data.ItemInfo +import com.android.launcher3.model.data.WorkspaceItemInfo +import com.android.launcher3.util.Executors +import com.android.launcher3.util.IntArray +import com.android.launcher3.util.same +import com.android.launcher3.util.eq +import com.android.launcher3.util.any +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyZeroInteractions +import org.mockito.Mockito.times +import org.mockito.Mockito.`when` as whenever + +/** + * Tests for [AddWorkspaceItemsTask] + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class AddWorkspaceItemsTaskTest : AbstractWorkspaceModelTest() { + + @Captor + private lateinit var mAnimatedItemArgumentCaptor: ArgumentCaptor> + + @Captor + private lateinit var mNotAnimatedItemArgumentCaptor: ArgumentCaptor> + + @Mock + private lateinit var mDataModelCallbacks: BgDataModel.Callbacks + + @Mock + private lateinit var mWorkspaceItemSpaceFinder: WorkspaceItemSpaceFinder + + + @Before + override fun setup() { + super.setup() + MockitoAnnotations.initMocks(this) + Executors.MAIN_EXECUTOR.submit { mModelHelper.model.addCallbacks(mDataModelCallbacks) } + .get() + } + + @After + override fun tearDown() { + super.tearDown() + } + + @Test + fun givenNewItemAndNonEmptyPages_whenExecuteTask_thenAddNewItem() { + val itemToAdd = getNewItem() + val nonEmptyScreenIds = listOf(0, 1, 2) + givenNewItemSpaces(NewItemSpace(1, 2, 2)) + + val addedItems = testAddItems(nonEmptyScreenIds, itemToAdd) + + assertThat(addedItems.size).isEqualTo(1) + assertThat(addedItems.first().itemInfo.screenId).isEqualTo(1) + assertThat(addedItems.first().isAnimated).isTrue() + verifyItemSpaceFinderCall(nonEmptyScreenIds, numberOfExpectedCall = 1) + } + + @Test + fun givenNewAndExistingItems_whenExecuteTask_thenOnlyAddNewItem() { + val itemsToAdd = arrayOf( + getNewItem(), + getExistingItem() + ) + givenNewItemSpaces(NewItemSpace(1, 0, 0)) + val nonEmptyScreenIds = listOf(0) + + val addedItems = testAddItems(nonEmptyScreenIds, *itemsToAdd) + + assertThat(addedItems.size).isEqualTo(1) + assertThat(addedItems.first().itemInfo.screenId).isEqualTo(1) + assertThat(addedItems.first().isAnimated).isTrue() + verifyItemSpaceFinderCall(nonEmptyScreenIds, numberOfExpectedCall = 1) + } + + @Test + fun givenOnlyExistingItem_whenExecuteTask_thenDoNotAddItem() { + val itemToAdd = getExistingItem() + givenNewItemSpaces(NewItemSpace(1, 0, 0)) + val nonEmptyScreenIds = listOf(0) + + val addedItems = testAddItems(nonEmptyScreenIds, itemToAdd) + + assertThat(addedItems.size).isEqualTo(0) + verifyZeroInteractions(mWorkspaceItemSpaceFinder, mDataModelCallbacks) + } + + @Test + fun givenNonSequentialScreenIds_whenExecuteTask_thenReturnNewScreenId() { + val itemToAdd = getNewItem() + givenNewItemSpaces(NewItemSpace(2, 1, 3)) + val nonEmptyScreenIds = listOf(0, 2, 3) + + val addedItems = testAddItems(nonEmptyScreenIds, itemToAdd) + + assertThat(addedItems.size).isEqualTo(1) + assertThat(addedItems.first().itemInfo.screenId).isEqualTo(2) + assertThat(addedItems.first().isAnimated).isTrue() + verifyItemSpaceFinderCall(nonEmptyScreenIds, numberOfExpectedCall = 1) + } + + @Test + fun givenMultipleItems_whenExecuteTask_thenAddThem() { + val itemsToAdd = arrayOf( + getNewItem(), + getExistingItem(), + getNewItem(), + getNewItem(), + getExistingItem(), + ) + givenNewItemSpaces( + NewItemSpace(1, 3, 3), + NewItemSpace(2, 0, 0), + NewItemSpace(2, 0, 1), + ) + val nonEmptyScreenIds = listOf(0, 1) + + val addedItems = testAddItems(nonEmptyScreenIds, *itemsToAdd) + + // Only the new items should be added + assertThat(addedItems.size).isEqualTo(3) + + // Items that are added to the first screen should not be animated + val itemsAddedToFirstScreen = addedItems.filter { it.itemInfo.screenId == 1 } + assertThat(itemsAddedToFirstScreen.size).isEqualTo(1) + assertThat(itemsAddedToFirstScreen.first().isAnimated).isFalse() + + // Items that are added to the second screen should be animated + val itemsAddedToSecondScreen = addedItems.filter { it.itemInfo.screenId == 2 } + assertThat(itemsAddedToSecondScreen.size).isEqualTo(2) + itemsAddedToSecondScreen.forEach { + assertThat(it.isAnimated).isTrue() + } + verifyItemSpaceFinderCall(nonEmptyScreenIds, numberOfExpectedCall = 3) + } + + /** + * Sets up the item space data that will be returned from WorkspaceItemSpaceFinder. + */ + private fun givenNewItemSpaces(vararg newItemSpaces: NewItemSpace) { + val spaceStack = newItemSpaces.toMutableList() + whenever( + mWorkspaceItemSpaceFinder.findSpaceForItem( + any(), + any(), + any(), + any(), + any(), + any() + ) + ) + .then { spaceStack.removeFirst().toIntArray() } + } + + /** + * Verifies if WorkspaceItemSpaceFinder was called with proper arguments and how many times was + * it called. + */ + private fun verifyItemSpaceFinderCall( + nonEmptyScreenIds: List, + numberOfExpectedCall: Int + ) { + verify(mWorkspaceItemSpaceFinder, times(numberOfExpectedCall)) + .findSpaceForItem( + same(mAppState), same(mModelHelper.bgDataModel), + eq(IntArray.wrap(*nonEmptyScreenIds.toIntArray())), eq(IntArray()), eq(1), eq(1) + ) + } + + /** + * Sets up the workspaces with items, executes the task, collects the added items from the + * model callback then returns it. + */ + private fun testAddItems( + nonEmptyScreenIds: List, + vararg itemsToAdd: WorkspaceItemInfo + ): List { + setupWorkspaces(nonEmptyScreenIds) + val task = newTask(*itemsToAdd) + var updateCount = 0 + mModelHelper.executeTaskForTest(task) + .forEach { + updateCount++ + it.run() + } + + val addedItems = mutableListOf() + if (updateCount > 0) { + verify(mDataModelCallbacks).bindAppsAdded( + any(), + mNotAnimatedItemArgumentCaptor.capture(), mAnimatedItemArgumentCaptor.capture() + ) + addedItems.addAll(mAnimatedItemArgumentCaptor.value.map { AddedItem(it, true) }) + addedItems.addAll(mNotAnimatedItemArgumentCaptor.value.map { AddedItem(it, false) }) + + } + + return addedItems + } + + /** + * Creates the task with the given items and replaces the WorkspaceItemSpaceFinder dependency + * with a mock. + */ + private fun newTask(vararg items: ItemInfo): AddWorkspaceItemsTask = + items.map { Pair.create(it, Any()) } + .toMutableList() + .let { AddWorkspaceItemsTask(it, mWorkspaceItemSpaceFinder) } +} + +private data class AddedItem( + val itemInfo: ItemInfo, + val isAnimated: Boolean +) diff --git a/tests/src/com/android/launcher3/model/WorkspaceItemSpaceFinderTest.kt b/tests/src/com/android/launcher3/model/WorkspaceItemSpaceFinderTest.kt new file mode 100644 index 0000000000..bfb1ac64c4 --- /dev/null +++ b/tests/src/com/android/launcher3/model/WorkspaceItemSpaceFinderTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.model + +import android.graphics.Rect +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests for [WorkspaceItemSpaceFinder] + */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class WorkspaceItemSpaceFinderTest : AbstractWorkspaceModelTest() { + + private val mItemSpaceFinder = WorkspaceItemSpaceFinder() + + @Before + override fun setup() { + super.setup() + } + + @After + override fun tearDown() { + super.tearDown() + } + + private fun findSpace(spanX: Int, spanY: Int): NewItemSpace = + mItemSpaceFinder.findSpaceForItem( + mAppState, mModelHelper.bgDataModel, + mExistingScreens, mNewScreens, spanX, spanY + ) + .let { NewItemSpace.fromIntArray(it) } + + private fun assertRegionVacant(newItemSpace: NewItemSpace, spanX: Int, spanY: Int) { + assertThat( + mScreenOccupancy[newItemSpace.screenId] + .isRegionVacant(newItemSpace.cellX, newItemSpace.cellY, spanX, spanY) + ).isTrue() + } + + @Test + fun justEnoughSpaceOnFirstScreen_whenFindSpaceForItem_thenReturnFirstScreenId() { + setupWorkspacesWithSpaces( + // 3x2 space on screen 0, but it should be skipped + screen0 = listOf(Rect(2, 0, 5, 2)), + screen1 = listOf(Rect(2, 2, 3, 3)), // 1x1 space + // 2 spaces of sizes 3x2 and 2x3 + screen2 = listOf(Rect(2, 0, 5, 2), Rect(0, 2, 2, 5)), + ) + + val spaceFound = findSpace(1, 1) + + assertThat(spaceFound.screenId).isEqualTo(1) + assertRegionVacant(spaceFound, 1, 1) + } + + @Test + fun notEnoughSpaceOnFirstScreen_whenFindSpaceForItem_thenReturnSecondScreenId() { + setupWorkspacesWithSpaces( + // 3x2 space on screen 0, but it should be skipped + screen0 = listOf(Rect(2, 0, 5, 2)), + screen1 = listOf(Rect(2, 2, 3, 3)), // 1x1 space + // 2 spaces of sizes 3x2 and 2x3 + screen2 = listOf(Rect(2, 0, 5, 2), Rect(0, 2, 2, 5)), + ) + + // Find a larger space + val spaceFound = findSpace(2, 3) + + assertThat(spaceFound.screenId).isEqualTo(2) + assertRegionVacant(spaceFound, 2, 3) + } + + @Test + fun notEnoughSpaceOnExistingScreens_returnNewScreenId() { + setupWorkspacesWithSpaces( + // 3x2 space on screen 0, but it should be skipped + screen0 = listOf(Rect(2, 0, 5, 2)), + // 2 spaces of sizes 3x2 and 2x3 + screen1 = listOf(Rect(2, 0, 5, 2), Rect(0, 2, 2, 5)), + // 2 spaces of sizes 1x2 and 2x2 + screen2 = listOf(Rect(1, 0, 2, 2), Rect(3, 2, 5, 4)), + ) + + val oldScreens = mExistingScreens.clone() + val spaceFound = findSpace(3, 3) + + assertThat(oldScreens.contains(spaceFound.screenId)).isFalse() + assertThat(mNewScreens.contains(spaceFound.screenId)).isTrue() + } + + @Test + fun firstScreenIsEmptyButSecondIsNotEmpty_returnSecondScreenId() { + setupWorkspacesWithSpaces( + // 3x2 space on screen 0, but it should be skipped + screen0 = listOf(Rect(2, 0, 5, 2)), + // empty screens are skipped + screen2 = listOf(Rect(2, 0, 5, 2)), // 3x2 space + ) + + val spaceFound = findSpace(2, 1) + + assertThat(spaceFound.screenId).isEqualTo(2) + assertRegionVacant(spaceFound, 2, 1) + } + + @Test + fun twoEmptyMiddleScreens_returnThirdScreen() { + setupWorkspacesWithSpaces( + // 3x2 space on screen 0, but it should be skipped + screen0 = listOf(Rect(2, 0, 5, 2)), + // empty screens are skipped + screen3 = listOf(Rect(1, 1, 4, 4)), // 3x3 space + ) + + val spaceFound = findSpace(2, 3) + + assertThat(spaceFound.screenId).isEqualTo(3) + assertRegionVacant(spaceFound, 2, 3) + } + + @Test + fun allExistingPagesAreFull_returnNewScreenId() { + setupWorkspacesWithSpaces( + // 3x2 space on screen 0, but it should be skipped + screen0 = listOf(Rect(2, 0, 5, 2)), + screen1 = fullScreenSpaces, + screen2 = fullScreenSpaces, + ) + + val spaceFound = findSpace(2, 3) + + assertThat(spaceFound.screenId).isEqualTo(3) + assertThat(mNewScreens.contains(spaceFound.screenId)).isTrue() + } + + @Test + fun firstTwoPagesAreFull_and_ThirdPageIsEmpty_returnThirdPage() { + setupWorkspacesWithSpaces( + // 3x2 space on screen 0, but it should be skipped + screen0 = listOf(Rect(2, 0, 5, 2)), + screen1 = fullScreenSpaces, // full screens are skipped + screen2 = fullScreenSpaces, // full screens are skipped + screen3 = emptyScreenSpaces + ) + + val spaceFound = findSpace(3, 1) + + assertThat(spaceFound.screenId).isEqualTo(3) + assertRegionVacant(spaceFound, 3, 1) + } +} diff --git a/tests/src/com/android/launcher3/util/KotlinMockitoHelpers.kt b/tests/src/com/android/launcher3/util/KotlinMockitoHelpers.kt new file mode 100644 index 0000000000..57db13ac45 --- /dev/null +++ b/tests/src/com/android/launcher3/util/KotlinMockitoHelpers.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.util + +/** + * Kotlin versions of popular mockito methods that can return null in situations when Kotlin expects + * a non-null value. Kotlin will throw an IllegalStateException when this takes place ("x must not + * be null"). To fix this, we can use methods that modify the return type to be nullable. This + * causes Kotlin to skip the null checks. + */ + +import org.mockito.ArgumentCaptor +import org.mockito.Mockito + +/** + * Returns Mockito.eq() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun eq(obj: T): T = Mockito.eq(obj) + +/** + * Returns Mockito.same() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun same(obj: T): T = Mockito.same(obj) + +/** + * Returns Mockito.any() as nullable type to avoid java.lang.IllegalStateException when + * null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun any(type: Class): T = Mockito.any(type) +inline fun any(): T = any(T::class.java) + +/** + * Kotlin type-inferred version of Mockito.nullable() + */ +inline fun nullable(): T? = Mockito.nullable(T::class.java) + +/** + * Returns ArgumentCaptor.capture() as nullable type to avoid java.lang.IllegalStateException + * when null is returned. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +fun capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture() + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun argumentCaptor(): ArgumentCaptor = + ArgumentCaptor.forClass(T::class.java) + +/** + * Helper function for creating new mocks, without the need to pass in a [Class] instance. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun mock(): T = Mockito.mock(T::class.java) + +/** + * A kotlin implemented wrapper of [ArgumentCaptor] which prevents the following exception when + * kotlin tests are mocking kotlin objects and the methods take non-null parameters: + * + * java.lang.NullPointerException: capture() must not be null + */ +class KotlinArgumentCaptor constructor(clazz: Class) { + private val wrapped: ArgumentCaptor = ArgumentCaptor.forClass(clazz) + fun capture(): T = wrapped.capture() + val value: T + get() = wrapped.value +} + +/** + * Helper function for creating an argumentCaptor in kotlin. + * + * Generic T is nullable because implicitly bounded by Any?. + */ +inline fun kotlinArgumentCaptor(): KotlinArgumentCaptor = + KotlinArgumentCaptor(T::class.java) + +/** + * Helper function for creating and using a single-use ArgumentCaptor in kotlin. + * + * val captor = argumentCaptor() + * verify(...).someMethod(captor.capture()) + * val captured = captor.value + * + * becomes: + * + * val captured = withArgCaptor { verify(...).someMethod(capture()) } + * + * NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException. + */ +inline fun withArgCaptor(block: KotlinArgumentCaptor.() -> Unit): T = + kotlinArgumentCaptor().apply { block() }.value