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
This commit is contained in:
Andras Kloczl
2021-11-22 16:54:54 +00:00
parent 60dc19cb71
commit 68e1af2bc2
7 changed files with 824 additions and 288 deletions
@@ -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<Pair<ItemInfo, Object>> mItemList;
private final WorkspaceItemSpaceFinder mItemSpaceFinder;
/**
* @param itemList items to add on the workspace
*/
public AddWorkspaceItemsTask(List<Pair<ItemInfo, Object>> itemList) {
this(itemList, new WorkspaceItemSpaceFinder());
}
/**
* @param itemList items to add on the workspace
* @param itemSpaceFinder inject WorkspaceItemSpaceFinder dependency for testing
*/
public AddWorkspaceItemsTask(List<Pair<ItemInfo, Object>> itemList,
WorkspaceItemSpaceFinder itemSpaceFinder) {
mItemList = itemList;
mItemSpaceFinder = itemSpaceFinder;
}
@Override
@@ -74,7 +79,7 @@ public class AddWorkspaceItemsTask extends BaseModelUpdateTask {
final ArrayList<ItemInfo> addedItemsFinal = new ArrayList<>();
final IntArray addedWorkspaceScreensFinal = new IntArray();
synchronized(dataModel) {
synchronized (dataModel) {
IntArray workspaceScreens = dataModel.collectWorkspaceScreens();
List<ItemInfo> 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<ArrayList<ItemInfo>> 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<ItemInfo> 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<ItemInfo> 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);
}
}
@@ -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<ArrayList<ItemInfo>> 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<ItemInfo> 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<ItemInfo> 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);
}
}
@@ -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<Rect>()
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<GridOccupancy>
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<Int>) {
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<Rect>? = null,
screen1: List<Rect>? = null,
screen2: List<Rect>? = null,
screen3: List<Rect>? = null,
) = listOf(screen0, screen1, screen2, screen3)
.let(this::setupWithSpaces)
private fun setupWithSpaces(workspaceSpaces: List<List<Rect>?>) {
var nextItemId = 1
workspaceSpaces.forEachIndexed { screenId, spaces ->
if (spaces != null) {
nextItemId = setupWorkspace(nextItemId, screenId, spaces)
}
}
}
private fun setupWorkspace(startId: Int, screenId: Int, spaces: List<Rect>): Int {
return mModelHelper.executeSimpleTask { dataModel ->
writeWorkspaceWithSpaces(dataModel, startId, screenId, spaces)
}
}
private fun writeWorkspaceWithSpaces(
bgDataModel: BgDataModel,
itemStartId: Int,
screenId: Int,
spaces: List<Rect>,
): 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])
}
}
@@ -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<GridOccupancy> 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<Pair<ItemInfo, Object>> 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<ArrayList> notAnimated = ArgumentCaptor.forClass(ArrayList.class);
ArgumentCaptor<ArrayList> 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;
}
}
@@ -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<ArrayList<ItemInfo>>
@Captor
private lateinit var mNotAnimatedItemArgumentCaptor: ArgumentCaptor<ArrayList<ItemInfo>>
@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<Int>,
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<Int>,
vararg itemsToAdd: WorkspaceItemInfo
): List<AddedItem> {
setupWorkspaces(nonEmptyScreenIds)
val task = newTask(*itemsToAdd)
var updateCount = 0
mModelHelper.executeTaskForTest(task)
.forEach {
updateCount++
it.run()
}
val addedItems = mutableListOf<AddedItem>()
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
)
@@ -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)
}
}
@@ -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 <T> eq(obj: T): T = Mockito.eq<T>(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 <T> same(obj: T): T = Mockito.same<T>(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 <T> any(type: Class<T>): T = Mockito.any<T>(type)
inline fun <reified T> any(): T = any(T::class.java)
/**
* Kotlin type-inferred version of Mockito.nullable()
*/
inline fun <reified T> 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 <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
/**
* Helper function for creating an argumentCaptor in kotlin.
*
* Generic T is nullable because implicitly bounded by Any?.
*/
inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> =
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 <reified T : Any> 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<T> constructor(clazz: Class<T>) {
private val wrapped: ArgumentCaptor<T> = 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 <reified T : Any> kotlinArgumentCaptor(): KotlinArgumentCaptor<T> =
KotlinArgumentCaptor(T::class.java)
/**
* Helper function for creating and using a single-use ArgumentCaptor in kotlin.
*
* val captor = argumentCaptor<Foo>()
* verify(...).someMethod(captor.capture())
* val captured = captor.value
*
* becomes:
*
* val captured = withArgCaptor<Foo> { verify(...).someMethod(capture()) }
*
* NOTE: this uses the KotlinArgumentCaptor to avoid the NullPointerException.
*/
inline fun <reified T : Any> withArgCaptor(block: KotlinArgumentCaptor<T>.() -> Unit): T =
kotlinArgumentCaptor<T>().apply { block() }.value