Merge "Fixed delay of thumbnail loading when going to overview from home" into main
diff --git a/quickstep/res/values-sw600dp/config.xml b/quickstep/res/values-sw600dp/config.xml
new file mode 100644
index 0000000..b22cfc5
--- /dev/null
+++ b/quickstep/res/values-sw600dp/config.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2023 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.
+-->
+<resources>
+    <!-- The number of thumbnails and icons to keep in the cache. The thumbnail cache size also
+         determines how many thumbnails will be fetched in the background. -->
+    <integer name="recentsThumbnailCacheSize">8</integer>
+</resources>
diff --git a/quickstep/src/com/android/quickstep/RecentTasksList.java b/quickstep/src/com/android/quickstep/RecentTasksList.java
index 0a7344a..6ee2cfd 100644
--- a/quickstep/src/com/android/quickstep/RecentTasksList.java
+++ b/quickstep/src/com/android/quickstep/RecentTasksList.java
@@ -353,7 +353,8 @@
         writer.println(prefix + "  ]");
     }
 
-    private static class TaskLoadResult extends ArrayList<GroupTask> {
+    @VisibleForTesting
+    static class TaskLoadResult extends ArrayList<GroupTask> {
 
         final int mRequestId;
 
diff --git a/quickstep/src/com/android/quickstep/RecentsModel.java b/quickstep/src/com/android/quickstep/RecentsModel.java
index d798e62..36a6eb6 100644
--- a/quickstep/src/com/android/quickstep/RecentsModel.java
+++ b/quickstep/src/com/android/quickstep/RecentsModel.java
@@ -17,15 +17,18 @@
 
 import static android.os.Process.THREAD_PRIORITY_BACKGROUND;
 
+import static com.android.launcher3.config.FeatureFlags.enableGridOnlyOverview;
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
 import static com.android.quickstep.TaskUtils.checkCurrentOrManagedUserId;
 
 import android.annotation.TargetApi;
 import android.app.ActivityManager;
 import android.app.KeyguardManager;
+import android.content.ComponentCallbacks;
 import android.content.ComponentCallbacks2;
 import android.content.Context;
 import android.content.Intent;
+import android.content.res.Configuration;
 import android.os.Build;
 import android.os.Process;
 import android.os.UserHandle;
@@ -36,6 +39,7 @@
 import com.android.launcher3.icons.IconProvider.IconChangeListener;
 import com.android.launcher3.util.Executors.SimpleThreadFactory;
 import com.android.launcher3.util.MainThreadInitializedObject;
+import com.android.launcher3.util.SafeCloseable;
 import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.util.TaskVisualsChangeListener;
 import com.android.systemui.shared.recents.model.Task;
@@ -57,7 +61,7 @@
  */
 @TargetApi(Build.VERSION_CODES.O)
 public class RecentsModel implements IconChangeListener, TaskStackChangeListener,
-        TaskVisualsChangeListener {
+        TaskVisualsChangeListener, SafeCloseable {
 
     // We do not need any synchronization for this variable as its only written on UI thread.
     public static final MainThreadInitializedObject<RecentsModel> INSTANCE =
@@ -72,17 +76,46 @@
     private final RecentTasksList mTaskList;
     private final TaskIconCache mIconCache;
     private final TaskThumbnailCache mThumbnailCache;
+    private final ComponentCallbacks mCallbacks;
 
     private RecentsModel(Context context) {
-        mContext = context;
-        mTaskList = new RecentTasksList(MAIN_EXECUTOR,
-                context.getSystemService(KeyguardManager.class),
-                SystemUiProxy.INSTANCE.get(context));
+        this(context, new IconProvider(context));
+    }
 
-        IconProvider iconProvider = new IconProvider(context);
-        mIconCache = new TaskIconCache(context, RECENTS_MODEL_EXECUTOR, iconProvider);
+    private RecentsModel(Context context, IconProvider iconProvider) {
+        this(context,
+                new RecentTasksList(MAIN_EXECUTOR,
+                        context.getSystemService(KeyguardManager.class),
+                        SystemUiProxy.INSTANCE.get(context)),
+                new TaskIconCache(context, RECENTS_MODEL_EXECUTOR, iconProvider),
+                new TaskThumbnailCache(context, RECENTS_MODEL_EXECUTOR),
+                iconProvider);
+    }
+
+    @VisibleForTesting
+    RecentsModel(Context context, RecentTasksList taskList, TaskIconCache iconCache,
+            TaskThumbnailCache thumbnailCache,
+            IconProvider iconProvider) {
+        mContext = context;
+        mTaskList = taskList;
+        mIconCache = iconCache;
         mIconCache.registerTaskVisualsChangeListener(this);
-        mThumbnailCache = new TaskThumbnailCache(context, RECENTS_MODEL_EXECUTOR);
+        mThumbnailCache = thumbnailCache;
+        if (enableGridOnlyOverview()) {
+            mCallbacks = new ComponentCallbacks() {
+                @Override
+                public void onConfigurationChanged(Configuration configuration) {
+                    updateCacheSizeAndPreloadIfNeeded();
+                }
+
+                @Override
+                public void onLowMemory() {
+                }
+            };
+            context.registerComponentCallbacks(mCallbacks);
+        } else {
+            mCallbacks = null;
+        }
 
         TaskStackChangeListeners.getInstance().registerTaskStackListener(this);
         iconProvider.registerIconChangeListener(this, MAIN_EXECUTOR.getHandler());
@@ -109,7 +142,6 @@
                 RecentsFilterState.DEFAULT_FILTER);
     }
 
-
     /**
      * Fetches the list of recent tasks, based on a filter
      *
@@ -183,8 +215,8 @@
                     // time the user next enters overview
                     continue;
                 }
-                mThumbnailCache.updateThumbnailInCache(group.task1);
-                mThumbnailCache.updateThumbnailInCache(group.task2);
+                mThumbnailCache.updateThumbnailInCache(group.task1, /* lowResolution= */ true);
+                mThumbnailCache.updateThumbnailInCache(group.task2, /* lowResolution= */ true);
             }
         });
     }
@@ -281,6 +313,54 @@
     }
 
     /**
+     * Preloads cache if enableGridOnlyOverview is true, preloading is enabled and
+     * highResLoadingState is enabled
+     */
+    public void preloadCacheIfNeeded() {
+        if (!enableGridOnlyOverview()) {
+            return;
+        }
+
+        if (!mThumbnailCache.isPreloadingEnabled()) {
+            // Skip if we aren't preloading.
+            return;
+        }
+
+        if (!mThumbnailCache.getHighResLoadingState().isEnabled()) {
+            // Skip if high-res loading state is disabled.
+            return;
+        }
+
+        mTaskList.getTaskKeys(mThumbnailCache.getCacheSize(), taskGroups -> {
+            for (GroupTask group : taskGroups) {
+                mThumbnailCache.updateThumbnailInCache(group.task1, /* lowResolution= */ false);
+                mThumbnailCache.updateThumbnailInCache(group.task2, /* lowResolution= */ false);
+            }
+        });
+    }
+
+    /**
+     * Updates cache size and preloads more tasks if cache size increases
+     */
+    public void updateCacheSizeAndPreloadIfNeeded() {
+        if (!enableGridOnlyOverview()) {
+            return;
+        }
+
+        // If new size is larger than original size, preload more cache to fill the gap
+        if (mThumbnailCache.updateCacheSizeAndRemoveExcess()) {
+            preloadCacheIfNeeded();
+        }
+    }
+
+    @Override
+    public void close() {
+        if (mCallbacks != null) {
+            mContext.unregisterComponentCallbacks(mCallbacks);
+        }
+    }
+
+    /**
      * Listener for receiving running tasks changes
      */
     public interface RunningTasksListener {
diff --git a/quickstep/src/com/android/quickstep/TaskThumbnailCache.java b/quickstep/src/com/android/quickstep/TaskThumbnailCache.java
index 3175ba8..2ca9f99 100644
--- a/quickstep/src/com/android/quickstep/TaskThumbnailCache.java
+++ b/quickstep/src/com/android/quickstep/TaskThumbnailCache.java
@@ -15,12 +15,18 @@
  */
 package com.android.quickstep;
 
+import static com.android.launcher3.config.FeatureFlags.enableGridOnlyOverview;
+
 import android.content.Context;
 import android.content.res.Resources;
 
+import androidx.annotation.VisibleForTesting;
+
 import com.android.launcher3.R;
 import com.android.launcher3.util.Preconditions;
 import com.android.quickstep.util.CancellableTask;
+import com.android.quickstep.util.TaskKeyByLastActiveTimeCache;
+import com.android.quickstep.util.TaskKeyCache;
 import com.android.quickstep.util.TaskKeyLruCache;
 import com.android.systemui.shared.recents.model.Task;
 import com.android.systemui.shared.recents.model.Task.TaskKey;
@@ -34,11 +40,10 @@
 public class TaskThumbnailCache {
 
     private final Executor mBgExecutor;
-
-    private final int mCacheSize;
-    private final TaskKeyLruCache<ThumbnailData> mCache;
+    private final TaskKeyCache<ThumbnailData> mCache;
     private final HighResLoadingState mHighResLoadingState;
     private final boolean mEnableTaskSnapshotPreloading;
+    private final Context mContext;
 
     public static class HighResLoadingState {
         private boolean mForceHighResThumbnails;
@@ -91,26 +96,39 @@
     }
 
     public TaskThumbnailCache(Context context, Executor bgExecutor) {
+        this(context, bgExecutor,
+                context.getResources().getInteger(R.integer.recentsThumbnailCacheSize));
+    }
+
+    private TaskThumbnailCache(Context context, Executor bgExecutor, int cacheSize) {
+        this(context, bgExecutor,
+                enableGridOnlyOverview() ? new TaskKeyByLastActiveTimeCache<>(cacheSize)
+                        : new TaskKeyLruCache<>(cacheSize));
+    }
+
+    @VisibleForTesting
+    TaskThumbnailCache(Context context, Executor bgExecutor, TaskKeyCache<ThumbnailData> cache) {
         mBgExecutor = bgExecutor;
         mHighResLoadingState = new HighResLoadingState(context);
+        mContext = context;
 
         Resources res = context.getResources();
-        mCacheSize = res.getInteger(R.integer.recentsThumbnailCacheSize);
         mEnableTaskSnapshotPreloading = res.getBoolean(R.bool.config_enableTaskSnapshotPreloading);
-        mCache = new TaskKeyLruCache<>(mCacheSize);
+        mCache = cache;
     }
 
     /**
-     * Synchronously fetches the thumbnail for the given {@param task} and puts it in the cache.
+     * Synchronously fetches the thumbnail for the given task at the specified resolution level, and
+     * puts it in the cache.
      */
-    public void updateThumbnailInCache(Task task) {
+    public void updateThumbnailInCache(Task task, boolean lowResolution) {
         if (task == null) {
             return;
         }
         Preconditions.assertUIThread();
         // Fetch the thumbnail for this task and put it in the cache
         if (task.thumbnail == null) {
-            updateThumbnailInBackground(task.key, true /* lowResolution */,
+            updateThumbnailInBackground(task.key, lowResolution,
                     t -> task.thumbnail = t);
         }
     }
@@ -148,6 +166,23 @@
         });
     }
 
+    /**
+     * Updates cache size and remove excess entries if current size is more than new cache size.
+     *
+     * @return whether cache size has increased
+     */
+    public boolean updateCacheSizeAndRemoveExcess() {
+        int newSize = mContext.getResources().getInteger(R.integer.recentsThumbnailCacheSize);
+        int oldSize = mCache.getMaxSize();
+        if (newSize == oldSize) {
+            // Return if no change in size
+            return false;
+        }
+
+        mCache.updateCacheSizeAndRemoveExcess(newSize);
+        return newSize > oldSize;
+    }
+
     private CancellableTask updateThumbnailInBackground(TaskKey key, boolean lowResolution,
             Consumer<ThumbnailData> callback) {
         Preconditions.assertUIThread();
@@ -169,6 +204,16 @@
 
             @Override
             public void handleResult(ThumbnailData result) {
+                // Avoid an async timing issue that a low res entry replaces an existing high res
+                // entry in high res enabled state, so we check before putting it to cache
+                if (enableGridOnlyOverview() && result.reducedResolution
+                        && getHighResLoadingState().isEnabled()) {
+                    ThumbnailData cachedThumbnail = mCache.getAndInvalidateIfModified(key);
+                    if (cachedThumbnail != null && cachedThumbnail.thumbnail != null
+                            && !cachedThumbnail.reducedResolution) {
+                        return;
+                    }
+                }
                 mCache.put(key, result);
                 callback.accept(result);
             }
@@ -195,7 +240,7 @@
      * @return The cache size.
      */
     public int getCacheSize() {
-        return mCacheSize;
+        return mCache.getMaxSize();
     }
 
     /**
diff --git a/quickstep/src/com/android/quickstep/util/TaskKeyByLastActiveTimeCache.java b/quickstep/src/com/android/quickstep/util/TaskKeyByLastActiveTimeCache.java
new file mode 100644
index 0000000..79ca076
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/util/TaskKeyByLastActiveTimeCache.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2023 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.quickstep.util;
+
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.android.systemui.shared.recents.model.Task;
+
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.PriorityQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Predicate;
+
+/**
+ * A class to cache task id and its corresponding object (e.g. thumbnail)
+ *
+ * <p>Maximum size of the cache should be provided when creating this class. When the number of
+ * entries is larger than its max size, it would remove the entry with the smallest last active time
+ * @param <V> Type of object stored in the cache
+ */
+public class TaskKeyByLastActiveTimeCache<V> implements TaskKeyCache<V> {
+    private static final String TAG = TaskKeyByLastActiveTimeCache.class.getSimpleName();
+    private final AtomicInteger mMaxSize;
+    private final Map<Integer, Entry<V>> mMap;
+    // To sort task id by last active time
+    private final PriorityQueue<Task.TaskKey> mQueue;
+
+    public TaskKeyByLastActiveTimeCache(int maxSize) {
+        mMap = new HashMap(maxSize);
+        mQueue = new PriorityQueue<>(Comparator.comparingLong(t -> t.lastActiveTime));
+        mMaxSize = new AtomicInteger(maxSize);
+    }
+
+    /**
+     * Removes all entries from the cache
+     */
+    @Override
+    public synchronized void evictAll() {
+        mMap.clear();
+        mQueue.clear();
+    }
+
+
+    /**
+     * Removes a particular entry from the cache
+     */
+    @Override
+    public synchronized void remove(Task.TaskKey key) {
+        if (key == null) {
+            return;
+        }
+
+        Entry<V> entry = mMap.remove(key.id);
+        if (entry != null) {
+            // Use real key in map entry to handle use case of using stub key for removal
+            mQueue.remove(entry.mKey);
+        }
+    }
+
+    /**
+     * Removes all entries matching keyCheck
+     */
+    @Override
+    public synchronized void removeAll(Predicate<Task.TaskKey> keyCheck) {
+        Iterator<Task.TaskKey> iterator = mQueue.iterator();
+        while (iterator.hasNext()) {
+            Task.TaskKey key = iterator.next();
+            if (keyCheck.test(key)) {
+                mMap.remove(key.id);
+                iterator.remove();
+            }
+        }
+    }
+
+    /**
+     * Gets the entry if it is still valid
+     */
+    @Override
+    public synchronized V getAndInvalidateIfModified(Task.TaskKey key) {
+        Entry<V> entry = mMap.get(key.id);
+        if (entry != null && entry.mKey.windowingMode == key.windowingMode
+                && entry.mKey.lastActiveTime == key.lastActiveTime) {
+            return entry.mValue;
+        } else {
+            remove(key);
+            return null;
+        }
+    }
+
+    /**
+     * Adds an entry to the cache, optionally evicting the last accessed entry
+     */
+    @Override
+    public final synchronized void put(Task.TaskKey key, V value) {
+        if (key != null && value != null) {
+            Entry<V> entry = mMap.get(key.id);
+            // If the same key already exist, remove item for existing key
+            if (entry != null) {
+                mQueue.remove(entry.mKey);
+            }
+
+            mMap.put(key.id, new Entry<>(key, value));
+            mQueue.add(key);
+            removeExcessIfNeeded();
+        } else {
+            Log.e(TAG, "Unexpected null key or value: " + key + ", " + value);
+        }
+    }
+
+    /**
+     * Updates the cache entry if it is already present in the cache
+     */
+    @Override
+    public synchronized void updateIfAlreadyInCache(int taskId, V data) {
+        Entry<V> entry = mMap.get(taskId);
+        if (entry != null) {
+            entry.mValue = data;
+        }
+    }
+
+    /**
+     * Updates cache size and remove excess if the number of existing entries is larger than new
+     * cache size
+     */
+    @Override
+    public synchronized void updateCacheSizeAndRemoveExcess(int cacheSize) {
+        mMaxSize.compareAndSet(mMaxSize.get(), cacheSize);
+        removeExcessIfNeeded();
+    }
+
+    private synchronized void removeExcessIfNeeded() {
+        while (mQueue.size() > mMaxSize.get() && !mQueue.isEmpty()) {
+            Task.TaskKey key = mQueue.poll();
+            mMap.remove(key.id);
+        }
+    }
+
+    /**
+     * Get maximum size of the cache
+     */
+    @Override
+    public int getMaxSize() {
+        return mMaxSize.get();
+    }
+
+    /**
+     * Get current size of the cache
+     */
+    @Override
+    public int getSize() {
+        return mMap.size();
+    }
+
+    @VisibleForTesting
+    PriorityQueue<Task.TaskKey> getQueue() {
+        return mQueue;
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/util/TaskKeyCache.java b/quickstep/src/com/android/quickstep/util/TaskKeyCache.java
new file mode 100644
index 0000000..8ee78ab
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/util/TaskKeyCache.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2023 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.quickstep.util;
+
+import com.android.systemui.shared.recents.model.Task;
+
+import java.util.function.Predicate;
+
+/**
+ * An interface for caching task id and its corresponding object (e.g. thumbnail, task icon)
+ *
+ * @param <V> Type of object stored in the cache
+ */
+public interface TaskKeyCache<V> {
+
+    /**
+     * Removes all entries from the cache.
+     */
+    void evictAll();
+
+    /**
+     * Removes a particular entry from the cache.
+     */
+    void remove(Task.TaskKey key);
+
+    /**
+     * Removes all entries matching keyCheck.
+     */
+    void removeAll(Predicate<Task.TaskKey> keyCheck);
+
+    /**
+     * Gets the entry if it is still valid.
+     */
+    V getAndInvalidateIfModified(Task.TaskKey key);
+
+    /**
+     * Adds an entry to the cache, optionally evicting the last accessed entry.
+     */
+    void put(Task.TaskKey key, V value);
+
+    /**
+     * Updates the cache entry if it is already present in the cache.
+     */
+    void updateIfAlreadyInCache(int taskId, V data);
+
+    /**
+     * Updates cache size and remove excess if the number of existing entries is larger than new
+     * cache size.
+     */
+    default void updateCacheSizeAndRemoveExcess(int cacheSize) { }
+
+    /**
+     * Gets maximum size of the cache.
+     */
+    int getMaxSize();
+
+    /**
+     * Gets current size of the cache.
+     */
+    int getSize();
+
+    class Entry<V> {
+
+        final Task.TaskKey mKey;
+        V mValue;
+
+        Entry(Task.TaskKey key, V value) {
+            mKey = key;
+            mValue = value;
+        }
+
+        @Override
+        public int hashCode() {
+            return mKey.id;
+        }
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/util/TaskKeyLruCache.java b/quickstep/src/com/android/quickstep/util/TaskKeyLruCache.java
index 08a65fa..89f5d41 100644
--- a/quickstep/src/com/android/quickstep/util/TaskKeyLruCache.java
+++ b/quickstep/src/com/android/quickstep/util/TaskKeyLruCache.java
@@ -27,7 +27,7 @@
  * A simple LRU cache for task key entries
  * @param <V> The type of the value
  */
-public class TaskKeyLruCache<V> {
+public class TaskKeyLruCache<V> implements TaskKeyCache<V> {
 
     private final MyLinkedHashMap<V> mMap;
 
@@ -92,20 +92,14 @@
         }
     }
 
-    private static class Entry<V> {
+    @Override
+    public int getMaxSize() {
+        return mMap.mMaxSize;
+    }
 
-        final TaskKey mKey;
-        V mValue;
-
-        Entry(TaskKey key, V value) {
-            mKey = key;
-            mValue = value;
-        }
-
-        @Override
-        public int hashCode() {
-            return mKey.id;
-        }
+    @Override
+    public int getSize() {
+        return mMap.size();
     }
 
     private static class MyLinkedHashMap<V> extends LinkedHashMap<Integer, Entry<V>> {
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index 8888c0d..825c0ae 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -2277,10 +2277,12 @@
         if (showAsGrid()) {
             int screenStart = mOrientationHandler.getPrimaryScroll(this);
             int pageOrientedSize = mOrientationHandler.getMeasuredSize(this);
-            int halfScreenSize = pageOrientedSize / 2;
-            // Use +/- 50% screen width as visible area.
-            visibleStart = screenStart - halfScreenSize;
-            visibleEnd = screenStart + pageOrientedSize + halfScreenSize;
+            // For GRID_ONLY_OVERVIEW, use +/- 1 task column as visible area for preloading
+            // adjacent thumbnails, otherwise use +/-50% screen width
+            int extraWidth = enableGridOnlyOverview() ? getLastComputedTaskSize().width()
+                    + getPageSpacing() : pageOrientedSize / 2;
+            visibleStart = screenStart - extraWidth;
+            visibleEnd = screenStart + pageOrientedSize + extraWidth;
         } else {
             int centerPageIndex = getPageNearestToCenterOfScreen();
             int numChildren = getChildCount();
@@ -2361,6 +2363,12 @@
 
     @Override
     public void onHighResLoadingStateChanged(boolean enabled) {
+        // Preload cache when no overview task is visible (e.g. not in overview page), so when
+        // user goes to overview next time, the task thumbnails would show up without delay
+        if (mHasVisibleTaskData.size() == 0) {
+            mModel.preloadCacheIfNeeded();
+        }
+
         // Whenever the high res loading state changes, poke each of the visible tasks to see if
         // they want to updated their thumbnail state
         for (int i = 0; i < mHasVisibleTaskData.size(); i++) {
diff --git a/quickstep/tests/src/com/android/quickstep/RecentsModelTest.java b/quickstep/tests/src/com/android/quickstep/RecentsModelTest.java
new file mode 100644
index 0000000..08e0898
--- /dev/null
+++ b/quickstep/tests/src/com/android/quickstep/RecentsModelTest.java
@@ -0,0 +1,177 @@
+/*
+ * Copyright (C) 2023 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.quickstep;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.ActivityManager;
+import android.content.Context;
+import android.content.res.Resources;
+import android.platform.test.flag.junit.SetFlagsRule;
+
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.filters.SmallTest;
+
+import com.android.launcher3.Flags;
+import com.android.launcher3.R;
+import com.android.launcher3.icons.IconProvider;
+import com.android.quickstep.util.GroupTask;
+import com.android.systemui.shared.recents.model.Task;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.function.Consumer;
+
+@SmallTest
+public class RecentsModelTest {
+    @Mock
+    private Context mContext;
+
+    @Mock
+    private TaskThumbnailCache mThumbnailCache;
+
+    @Mock
+    private RecentTasksList mTasksList;
+
+    @Mock
+    private TaskThumbnailCache.HighResLoadingState mHighResLoadingState;
+
+    private RecentsModel mRecentsModel;
+
+    private RecentTasksList.TaskLoadResult mTaskResult;
+
+    private Resources mResource;
+
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
+    @Before
+    public void setup() throws NoSuchFieldException {
+        MockitoAnnotations.initMocks(this);
+        mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_GRID_ONLY_OVERVIEW);
+        mTaskResult = getTaskResult();
+        doAnswer(invocation-> {
+            Consumer<ArrayList<GroupTask>> callback = invocation.getArgument(1);
+            callback.accept(mTaskResult);
+            return null;
+        }).when(mTasksList).getTaskKeys(anyInt(), any());
+
+        when(mHighResLoadingState.isEnabled()).thenReturn(true);
+        when(mThumbnailCache.getHighResLoadingState()).thenReturn(mHighResLoadingState);
+        when(mThumbnailCache.isPreloadingEnabled()).thenReturn(true);
+
+        mRecentsModel = new RecentsModel(mContext, mTasksList, mock(TaskIconCache.class),
+                mThumbnailCache, mock(IconProvider.class));
+
+        mResource = mock(Resources.class);
+        when(mResource.getInteger((R.integer.recentsThumbnailCacheSize))).thenReturn(3);
+        when(mContext.getResources()).thenReturn(mResource);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_GRID_ONLY_OVERVIEW);
+    }
+
+    @Test
+    @UiThreadTest
+    public void preloadOnHighResolutionEnabled() {
+        mRecentsModel.preloadCacheIfNeeded();
+
+        ArgumentCaptor<Task> taskArgs = ArgumentCaptor.forClass(Task.class);
+        verify(mRecentsModel.getThumbnailCache(), times(2))
+                .updateThumbnailInCache(taskArgs.capture(), /* lowResolution= */ eq(false));
+
+        GroupTask expectedGroupTask = mTaskResult.get(0);
+        assertThat(taskArgs.getAllValues().get(0)).isEqualTo(
+                expectedGroupTask.task1);
+        assertThat(taskArgs.getAllValues().get(1)).isEqualTo(
+                expectedGroupTask.task2);
+    }
+
+    @Test
+    public void notPreloadOnHighResolutionDisabled() {
+        when(mHighResLoadingState.isEnabled()).thenReturn(false);
+        when(mThumbnailCache.isPreloadingEnabled()).thenReturn(true);
+        mRecentsModel.preloadCacheIfNeeded();
+        verify(mRecentsModel.getThumbnailCache(), never())
+                .updateThumbnailInCache(any(), anyBoolean());
+    }
+
+    @Test
+    public void notPreloadOnPreloadDisabled() {
+        when(mThumbnailCache.isPreloadingEnabled()).thenReturn(false);
+        mRecentsModel.preloadCacheIfNeeded();
+        verify(mRecentsModel.getThumbnailCache(), never())
+                .updateThumbnailInCache(any(), anyBoolean());
+
+    }
+
+    @Test
+    public void increaseCacheSizeAndPreload() {
+        // Mock to return preload is needed
+        when(mThumbnailCache.updateCacheSizeAndRemoveExcess()).thenReturn(true);
+        // Update cache size
+        mRecentsModel.updateCacheSizeAndPreloadIfNeeded();
+        // Assert update cache is called
+        verify(mRecentsModel.getThumbnailCache(), times(2))
+                .updateThumbnailInCache(any(), /* lowResolution= */ eq(false));
+    }
+
+    @Test
+    public void decreaseCacheSizeAndNotPreload() {
+        // Mock to return preload is not needed
+        when(mThumbnailCache.updateCacheSizeAndRemoveExcess()).thenReturn(false);
+        // Update cache size
+        mRecentsModel.updateCacheSizeAndPreloadIfNeeded();
+        // Assert update cache is never called
+        verify(mRecentsModel.getThumbnailCache(), never())
+                .updateThumbnailInCache(any(), anyBoolean());
+    }
+
+    private RecentTasksList.TaskLoadResult getTaskResult() {
+        RecentTasksList.TaskLoadResult allTasks = new RecentTasksList.TaskLoadResult(0, false, 1);
+        ActivityManager.RecentTaskInfo taskInfo1 = new ActivityManager.RecentTaskInfo();
+        Task.TaskKey taskKey1 = new Task.TaskKey(taskInfo1);
+        Task task1 = Task.from(taskKey1, taskInfo1, false);
+
+        ActivityManager.RecentTaskInfo taskInfo2 = new ActivityManager.RecentTaskInfo();
+        Task.TaskKey taskKey2 = new Task.TaskKey(taskInfo2);
+        Task task2 = Task.from(taskKey2, taskInfo2, false);
+
+        allTasks.add(new GroupTask(task1, task2, null));
+        return allTasks;
+    }
+}
diff --git a/quickstep/tests/src/com/android/quickstep/TaskThumbnailCacheTest.java b/quickstep/tests/src/com/android/quickstep/TaskThumbnailCacheTest.java
new file mode 100644
index 0000000..4e04261
--- /dev/null
+++ b/quickstep/tests/src/com/android/quickstep/TaskThumbnailCacheTest.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2023 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.quickstep;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.res.Resources;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.launcher3.R;
+import com.android.quickstep.util.TaskKeyCache;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.concurrent.Executor;
+
+@SmallTest
+public class TaskThumbnailCacheTest {
+    @Mock
+    private Context mContext;
+
+    @Mock
+    private Resources mResource;
+
+    @Mock
+    private TaskKeyCache mTaskKeyCache;
+
+    @Before
+    public void setup() throws NoSuchFieldException {
+        MockitoAnnotations.initMocks(this);
+        when(mContext.getResources()).thenReturn(mResource);
+    }
+
+    @Test
+    public void increaseCacheSize() {
+        // Mock a cache size increase from 3 to 8
+        when(mTaskKeyCache.getMaxSize()).thenReturn(3);
+        when(mResource.getInteger((R.integer.recentsThumbnailCacheSize))).thenReturn(8);
+        TaskThumbnailCache thumbnailCache = new TaskThumbnailCache(mContext, mock(Executor.class),
+                mTaskKeyCache);
+
+        // Preload is needed when increasing size
+        assertTrue(thumbnailCache.updateCacheSizeAndRemoveExcess());
+        verify(mTaskKeyCache, times(1)).updateCacheSizeAndRemoveExcess(8);
+    }
+
+    @Test
+    public void decreaseCacheSize() {
+        // Mock a cache size decrease from 8 to 3
+        when(mTaskKeyCache.getMaxSize()).thenReturn(8);
+        when(mResource.getInteger((R.integer.recentsThumbnailCacheSize))).thenReturn(3);
+        TaskThumbnailCache thumbnailCache = new TaskThumbnailCache(mContext, mock(Executor.class),
+                mTaskKeyCache);
+        // Preload is not needed when decreasing size
+        assertFalse(thumbnailCache.updateCacheSizeAndRemoveExcess());
+        verify(mTaskKeyCache, times(1)).updateCacheSizeAndRemoveExcess(3);
+    }
+
+    @Test
+    public void keepSameCacheSize() {
+        when(mTaskKeyCache.getMaxSize()).thenReturn(3);
+        when(mResource.getInteger((R.integer.recentsThumbnailCacheSize))).thenReturn(3);
+        TaskThumbnailCache thumbnailCache = new TaskThumbnailCache(mContext, mock(Executor.class),
+                mTaskKeyCache);
+        // Preload is not needed when it has the same cache size
+        assertFalse(thumbnailCache.updateCacheSizeAndRemoveExcess());
+        verify(mTaskKeyCache, never()).updateCacheSizeAndRemoveExcess(anyInt());
+    }
+}
diff --git a/quickstep/tests/src/com/android/quickstep/util/TaskKeyByLastActiveTimeCacheTest.java b/quickstep/tests/src/com/android/quickstep/util/TaskKeyByLastActiveTimeCacheTest.java
new file mode 100644
index 0000000..ea2688a
--- /dev/null
+++ b/quickstep/tests/src/com/android/quickstep/util/TaskKeyByLastActiveTimeCacheTest.java
@@ -0,0 +1,281 @@
+/*
+ * Copyright (C) 2023 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.quickstep.util;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertNull;
+
+import android.content.ComponentName;
+import android.content.Intent;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.shared.recents.model.Task;
+import com.android.systemui.shared.recents.model.ThumbnailData;
+
+import org.junit.Test;
+
+@SmallTest
+public class TaskKeyByLastActiveTimeCacheTest {
+    @Test
+    public void add() {
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(3);
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 1);
+        ThumbnailData data1 = new ThumbnailData();
+        cache.put(key1, data1);
+
+        Task.TaskKey key2 = new Task.TaskKey(2, 0, new Intent(),
+                new ComponentName("", ""), 0, 2);
+        ThumbnailData data2 = new ThumbnailData();
+        cache.put(key2, data2);
+
+        assertEquals(2, cache.getSize());
+        assertEquals(data1, cache.getAndInvalidateIfModified(key1));
+        assertEquals(data2, cache.getAndInvalidateIfModified(key2));
+
+        assertEquals(2, cache.getQueue().size());
+        assertEquals(key1, cache.getQueue().poll());
+        assertEquals(key2, cache.getQueue().poll());
+    }
+
+    @Test
+    public void addSameTasksWithSameLastActiveTimeTwice() {
+        // Add 2 tasks with same id and last active time, it should only have 1 entry in cache
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(3);
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 1000);
+        ThumbnailData data1 = new ThumbnailData();
+        cache.put(key1, data1);
+
+        Task.TaskKey key2 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 1000);
+        ThumbnailData data2 = new ThumbnailData();
+        cache.put(key2, data2);
+
+        assertEquals(1, cache.getSize());
+        assertEquals(data2, cache.getAndInvalidateIfModified(key2));
+
+        assertEquals(1, cache.getQueue().size());
+        assertEquals(key2, cache.getQueue().poll());
+    }
+
+    @Test
+    public void addSameTasksWithDifferentLastActiveTime() {
+        // Add 2 tasks with same id and different last active time, it should only have the
+        // higher last active time entry
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(3);
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 1000);
+        ThumbnailData data1 = new ThumbnailData();
+        cache.put(key1, data1);
+
+        Task.TaskKey key2 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 2000);
+        ThumbnailData data2 = new ThumbnailData();
+        cache.put(key2, data2);
+
+        assertEquals(1, cache.getSize());
+        assertEquals(data2, cache.getAndInvalidateIfModified(key2));
+
+        assertEquals(1, cache.getQueue().size());
+        Task.TaskKey queueKey = cache.getQueue().poll();
+        assertEquals(key2, queueKey);
+        // TaskKey's equal method does not check last active time, so we check here
+        assertEquals(2000, queueKey.lastActiveTime);
+    }
+
+    @Test
+    public void remove() {
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(3);
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 0);
+        cache.put(key1, new ThumbnailData());
+
+        cache.remove(key1);
+
+        assertEquals(0, cache.getSize());
+        assertEquals(0, cache.getQueue().size());
+    }
+
+    @Test
+    public void removeByStubKey() {
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(3);
+        Task.TaskKey key1 = new Task.TaskKey(1, 1, new Intent(),
+                new ComponentName("", ""), 1, 100);
+        cache.put(key1, new ThumbnailData());
+
+        Task.TaskKey stubKey = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 0);
+        cache.remove(stubKey);
+
+        assertEquals(0, cache.getSize());
+        assertEquals(0, cache.getQueue().size());
+    }
+
+    @Test
+    public void evictAll() {
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(3);
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 0);
+        cache.put(key1, new ThumbnailData());
+        Task.TaskKey key2 = new Task.TaskKey(2, 0, new Intent(),
+                new ComponentName("", ""), 0, 0);
+        cache.put(key2, new ThumbnailData());
+
+        cache.evictAll();
+
+        assertEquals(0, cache.getSize());
+        assertEquals(0, cache.getQueue().size());
+    }
+
+    @Test
+    public void removeAllByPredicate() {
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(3);
+        // Add user 1's tasks
+        Task.TaskKey user1Key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 1, 0);
+        cache.put(user1Key1, new ThumbnailData());
+        Task.TaskKey user1Key2 = new Task.TaskKey(2, 0, new Intent(),
+                new ComponentName("", ""), 1, 0);
+        cache.put(user1Key2, new ThumbnailData());
+        // Add user 2's task
+        Task.TaskKey user2Key = new Task.TaskKey(3, 0, new Intent(),
+                new ComponentName("", ""), 2, 0);
+        ThumbnailData user2Data = new ThumbnailData();
+        cache.put(user2Key, user2Data);
+
+        cache.removeAll(key -> key.userId == 1);
+
+        // Only user 2's task remains
+        assertEquals(1, cache.getSize());
+        assertEquals(user2Data, cache.getAndInvalidateIfModified(user2Key));
+
+        assertEquals(1, cache.getQueue().size());
+        assertEquals(user2Key, cache.getQueue().poll());
+    }
+
+    @Test
+    public void getAndInvalidateIfModified() {
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(3);
+        // Add user 1's tasks
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 1, 0);
+        ThumbnailData data1 = new ThumbnailData();
+        cache.put(key1, data1);
+
+        // Get result with task key of same last active time
+        Task.TaskKey keyWithSameActiveTime = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 1, 0);
+        ThumbnailData result1 = cache.getAndInvalidateIfModified(keyWithSameActiveTime);
+        assertEquals(data1, result1);
+        assertEquals(1, cache.getQueue().size());
+
+        // Invalidate result with task key of new last active time
+        Task.TaskKey keyWithNewActiveTime = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 1, 1);
+        ThumbnailData result2 = cache.getAndInvalidateIfModified(keyWithNewActiveTime);
+        // No entry is retrieved because the key has higher last active time
+        assertNull(result2);
+        assertEquals(0, cache.getSize());
+        assertEquals(0, cache.getQueue().size());
+    }
+
+    @Test
+    public void removeByLastActiveTimeWhenOverMaxSize() {
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(2);
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 200);
+        ThumbnailData task1 = new ThumbnailData();
+        cache.put(key1, task1);
+        Task.TaskKey key2 = new Task.TaskKey(2, 0, new Intent(),
+                new ComponentName("", ""), 0, 100);
+        ThumbnailData task2 = new ThumbnailData();
+        cache.put(key2, task2);
+
+        // Add the 3rd entry which will exceed the max cache size
+        Task.TaskKey key3 = new Task.TaskKey(3, 0, new Intent(),
+                new ComponentName("", ""), 0, 300);
+        ThumbnailData task3 = new ThumbnailData();
+        cache.put(key3, task3);
+
+        // Assert map size and check the remaining entries have higher active time
+        assertEquals(2, cache.getSize());
+        assertEquals(task1, cache.getAndInvalidateIfModified(key1));
+        assertEquals(task3, cache.getAndInvalidateIfModified(key3));
+        assertNull(cache.getAndInvalidateIfModified(key2));
+
+        // Assert queue size and check the remaining entries have higher active time
+        assertEquals(2, cache.getQueue().size());
+        Task.TaskKey queueKey1 = cache.getQueue().poll();
+        assertEquals(key1, queueKey1);
+        assertEquals(200, queueKey1.lastActiveTime);
+        Task.TaskKey queueKey2 = cache.getQueue().poll();
+        assertEquals(key3, queueKey2);
+        assertEquals(300, queueKey2.lastActiveTime);
+    }
+
+    @Test
+    public void updateIfAlreadyInCache() {
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(2);
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 200);
+        cache.put(key1, new ThumbnailData());
+
+        // Update original data to new data
+        ThumbnailData newData = new ThumbnailData();
+        cache.updateIfAlreadyInCache(key1.id, newData);
+
+        // Data is updated to newData successfully
+        ThumbnailData result = cache.getAndInvalidateIfModified(key1);
+        assertEquals(newData, result);
+    }
+
+    @Test
+    public void updateCacheSizeAndInvalidateExcess() {
+        // Last active time are not in-sync with insertion order to simulate the real async case
+        TaskKeyByLastActiveTimeCache<ThumbnailData> cache = new TaskKeyByLastActiveTimeCache<>(4);
+        Task.TaskKey key1 = new Task.TaskKey(1, 0, new Intent(),
+                new ComponentName("", ""), 0, 200);
+        cache.put(key1, new ThumbnailData());
+
+        Task.TaskKey key2 = new Task.TaskKey(2, 0, new Intent(),
+                new ComponentName("", ""), 0, 100);
+        cache.put(key2, new ThumbnailData());
+
+        Task.TaskKey key3 = new Task.TaskKey(3, 0, new Intent(),
+                new ComponentName("", ""), 0, 400);
+        cache.put(key3, new ThumbnailData());
+
+        Task.TaskKey key4 = new Task.TaskKey(4, 0, new Intent(),
+                new ComponentName("", ""), 0, 300);
+        cache.put(key4, new ThumbnailData());
+
+        // Check that it has 4 entries before cache size changes
+        assertEquals(4, cache.getSize());
+        assertEquals(4, cache.getQueue().size());
+
+        // Update size to 2
+        cache.updateCacheSizeAndRemoveExcess(2);
+
+        // Number of entries becomes 2, only key3 and key4 remain
+        assertEquals(2, cache.getSize());
+        assertEquals(2, cache.getQueue().size());
+        assertNotNull(cache.getAndInvalidateIfModified(key3));
+        assertNotNull(cache.getAndInvalidateIfModified(key4));
+    }
+}