Merge "Load widget preview images before adding the rows to the adapter" into sc-dev
diff --git a/robolectric_tests/src/com/android/launcher3/widget/CachingWidgetPreviewLoaderTest.java b/robolectric_tests/src/com/android/launcher3/widget/CachingWidgetPreviewLoaderTest.java
new file mode 100644
index 0000000..c18e26c
--- /dev/null
+++ b/robolectric_tests/src/com/android/launcher3/widget/CachingWidgetPreviewLoaderTest.java
@@ -0,0 +1,409 @@
+/*
+ * Copyright (C) 2021 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.widget;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import android.content.ComponentName;
+import android.graphics.Bitmap;
+import android.os.CancellationSignal;
+import android.os.UserHandle;
+import android.util.Size;
+
+import com.android.launcher3.DeviceProfile;
+import com.android.launcher3.InvariantDeviceProfile;
+import com.android.launcher3.icons.IconCache;
+import com.android.launcher3.model.WidgetItem;
+import com.android.launcher3.testing.TestActivity;
+import com.android.launcher3.widget.WidgetPreviewLoader.WidgetPreviewLoadedCallback;
+
+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.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+@RunWith(RobolectricTestRunner.class)
+public class CachingWidgetPreviewLoaderTest {
+    private static final Size SIZE_10_10 = new Size(10, 10);
+    private static final Size SIZE_20_20 = new Size(20, 20);
+    private static final String TEST_PACKAGE = "com.example.test";
+    private static final ComponentName TEST_PROVIDER =
+            new ComponentName(TEST_PACKAGE, ".WidgetProvider");
+    private static final ComponentName TEST_PROVIDER2 =
+            new ComponentName(TEST_PACKAGE, ".WidgetProvider2");
+    private static final Bitmap BITMAP = Bitmap.createBitmap(10, 10, Bitmap.Config.ARGB_8888);
+    private static final Bitmap BITMAP2 = Bitmap.createBitmap(20, 20, Bitmap.Config.ARGB_8888);
+
+
+    @Mock private CancellationSignal mCancellationSignal;
+    @Mock private WidgetPreviewLoader mDelegate;
+    @Mock private IconCache mIconCache;
+    @Mock private DeviceProfile mDeviceProfile;
+    @Mock private LauncherAppWidgetProviderInfo mProviderInfo;
+    @Mock private LauncherAppWidgetProviderInfo mProviderInfo2;
+    @Mock private WidgetPreviewLoadedCallback mPreviewLoadedCallback;
+    @Mock private WidgetPreviewLoadedCallback mPreviewLoadedCallback2;
+    @Captor private ArgumentCaptor<WidgetPreviewLoadedCallback> mCallbackCaptor;
+
+    private TestActivity mTestActivity;
+    private CachingWidgetPreviewLoader mLoader;
+    private WidgetItem mWidgetItem;
+    private WidgetItem mWidgetItem2;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        mLoader = new CachingWidgetPreviewLoader(mDelegate);
+
+        mTestActivity = Robolectric.buildActivity(TestActivity.class).setup().get();
+        mTestActivity.setDeviceProfile(mDeviceProfile);
+
+        when(mDelegate.loadPreview(any(), any(), any(), any())).thenReturn(mCancellationSignal);
+
+        mProviderInfo.provider = TEST_PROVIDER;
+        when(mProviderInfo.getProfile()).thenReturn(new UserHandle(0));
+
+        mProviderInfo2.provider = TEST_PROVIDER2;
+        when(mProviderInfo2.getProfile()).thenReturn(new UserHandle(0));
+
+        InvariantDeviceProfile testProfile = new InvariantDeviceProfile();
+        testProfile.numRows = 5;
+        testProfile.numColumns = 5;
+
+        mWidgetItem = new WidgetItem(mProviderInfo, testProfile, mIconCache);
+        mWidgetItem2 = new WidgetItem(mProviderInfo2, testProfile, mIconCache);
+    }
+
+    @Test
+    public void getPreview_notInCache_shouldReturnNull() {
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_10_10)).isNull();
+    }
+
+    @Test
+    public void getPreview_notInCache_shouldNotCallDelegate() {
+        mLoader.getPreview(mWidgetItem, SIZE_10_10);
+
+        verifyZeroInteractions(mDelegate);
+    }
+
+    @Test
+    public void getPreview_inCache_shouldReturnCachedBitmap() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_10_10)).isEqualTo(BITMAP);
+    }
+
+    @Test
+    public void getPreview_otherSizeInCache_shouldReturnNull() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_20_20)).isNull();
+    }
+
+    @Test
+    public void getPreview_otherItemInCache_shouldReturnNull() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+
+        assertThat(mLoader.getPreview(mWidgetItem2, SIZE_10_10)).isNull();
+    }
+
+    @Test
+    public void getPreview_shouldStoreMultipleSizesPerItem() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+        loadPreviewIntoCache(mWidgetItem, SIZE_20_20, BITMAP2);
+
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_10_10)).isEqualTo(BITMAP);
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_20_20)).isEqualTo(BITMAP2);
+    }
+
+    @Test
+    public void loadPreview_notInCache_shouldStartLoading() {
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+
+        verify(mDelegate).loadPreview(eq(mTestActivity), eq(mWidgetItem), eq(SIZE_10_10), any());
+        verifyZeroInteractions(mPreviewLoadedCallback);
+    }
+
+    @Test
+    public void loadPreview_thenLoaded_shouldCallBack() {
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+        verify(mDelegate).loadPreview(any(), any(), any(), mCallbackCaptor.capture());
+        WidgetPreviewLoadedCallback loaderCallback = mCallbackCaptor.getValue();
+
+        loaderCallback.onPreviewLoaded(BITMAP);
+
+        verify(mPreviewLoadedCallback).onPreviewLoaded(BITMAP);
+    }
+
+    @Test
+    public void loadPreview_thenCancelled_shouldCancelDelegateRequest() {
+        CancellationSignal cancellationSignal =
+                mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+
+        cancellationSignal.cancel();
+
+        verify(mCancellationSignal).cancel();
+        verifyZeroInteractions(mPreviewLoadedCallback);
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_10_10)).isNull();
+    }
+
+    @Test
+    public void loadPreview_thenCancelled_otherCallListening_shouldNotCancelDelegateRequest() {
+        CancellationSignal cancellationSignal1 =
+                mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback2);
+
+        cancellationSignal1.cancel();
+
+        verifyZeroInteractions(mCancellationSignal);
+    }
+
+    @Test
+    public void loadPreview_thenCancelled_otherCallListening_loaded_shouldCallBackToNonCancelled() {
+        CancellationSignal cancellationSignal1 =
+                mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback2);
+        verify(mDelegate).loadPreview(any(), any(), any(), mCallbackCaptor.capture());
+        WidgetPreviewLoadedCallback loaderCallback = mCallbackCaptor.getValue();
+
+        cancellationSignal1.cancel();
+        loaderCallback.onPreviewLoaded(BITMAP);
+
+        verifyZeroInteractions(mPreviewLoadedCallback);
+        verify(mPreviewLoadedCallback2).onPreviewLoaded(BITMAP);
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_10_10)).isEqualTo(BITMAP);
+    }
+
+    @Test
+    public void loadPreview_thenCancelled_bothCallsCancelled_shouldCancelDelegateRequest() {
+        CancellationSignal cancellationSignal1 =
+                mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+        CancellationSignal cancellationSignal2 =
+                mLoader.loadPreview(
+                        mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback2);
+
+        cancellationSignal1.cancel();
+        cancellationSignal2.cancel();
+
+        verify(mCancellationSignal).cancel();
+        verifyZeroInteractions(mPreviewLoadedCallback);
+        verifyZeroInteractions(mPreviewLoadedCallback2);
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_10_10)).isNull();
+    }
+
+    @Test
+    public void loadPreview_multipleCallbacks_shouldOnlyCallDelegateOnce() {
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback2);
+
+        verify(mDelegate).loadPreview(any(), any(), any(), any());
+    }
+
+    @Test
+    public void loadPreview_multipleCallbacks_shouldForwardResultToEachCallback() {
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback2);
+
+        verify(mDelegate).loadPreview(any(), any(), any(), mCallbackCaptor.capture());
+        WidgetPreviewLoadedCallback loaderCallback = mCallbackCaptor.getValue();
+
+        loaderCallback.onPreviewLoaded(BITMAP);
+
+        verify(mPreviewLoadedCallback).onPreviewLoaded(BITMAP);
+        verify(mPreviewLoadedCallback2).onPreviewLoaded(BITMAP);
+    }
+
+    @Test
+    public void loadPreview_inCache_shouldCallBackImmediately() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+        reset(mDelegate);
+
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+
+        verify(mPreviewLoadedCallback).onPreviewLoaded(BITMAP);
+        verifyZeroInteractions(mDelegate);
+    }
+
+    @Test
+    public void loadPreview_thenLoaded_thenCancelled_shouldNotRemovePreviewFromCache() {
+        CancellationSignal cancellationSignal =
+                mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+        verify(mDelegate).loadPreview(any(), any(), any(), mCallbackCaptor.capture());
+        WidgetPreviewLoadedCallback loaderCallback = mCallbackCaptor.getValue();
+        loaderCallback.onPreviewLoaded(BITMAP);
+
+        cancellationSignal.cancel();
+
+        assertThat(mLoader.getPreview(mWidgetItem, SIZE_10_10)).isEqualTo(BITMAP);
+    }
+
+    @Test
+    public void isPreviewLoaded_notLoaded_shouldReturnFalse() {
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+    }
+
+    @Test
+    public void isPreviewLoaded_otherSizeLoaded_shouldReturnFalse() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_20_20, BITMAP);
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+    }
+
+    @Test
+    public void isPreviewLoaded_otherItemLoaded_shouldReturnFalse() {
+        loadPreviewIntoCache(mWidgetItem2, SIZE_10_10, BITMAP);
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+    }
+
+    @Test
+    public void isPreviewLoaded_loaded_shouldReturnTrue() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isTrue();
+    }
+
+    @Test
+    public void clearPreviews_notInCache_shouldBeNoOp() {
+        mLoader.clearPreviews(Collections.singletonList(mWidgetItem));
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+    }
+
+    @Test
+    public void clearPreviews_inCache_shouldRemovePreview() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+
+        mLoader.clearPreviews(Collections.singletonList(mWidgetItem));
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+    }
+
+    @Test
+    public void clearPreviews_inCache_multipleSizes_shouldRemoveAllSizes() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+        loadPreviewIntoCache(mWidgetItem, SIZE_20_20, BITMAP);
+
+        mLoader.clearPreviews(Collections.singletonList(mWidgetItem));
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_20_20)).isFalse();
+    }
+
+    @Test
+    public void clearPreviews_inCache_otherItems_shouldOnlyRemoveSpecifiedItems() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+        loadPreviewIntoCache(mWidgetItem2, SIZE_10_10, BITMAP);
+
+        mLoader.clearPreviews(Collections.singletonList(mWidgetItem));
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem2, SIZE_10_10)).isTrue();
+    }
+
+    @Test
+    public void clearPreviews_inCache_otherItems_shouldRemoveAllSpecifiedItems() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+        loadPreviewIntoCache(mWidgetItem2, SIZE_10_10, BITMAP);
+
+        mLoader.clearPreviews(Arrays.asList(mWidgetItem, mWidgetItem2));
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem2, SIZE_10_10)).isFalse();
+    }
+
+    @Test
+    public void clearPreviews_loading_shouldCancelLoad() {
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+
+        mLoader.clearPreviews(Collections.singletonList(mWidgetItem));
+
+        verify(mCancellationSignal).cancel();
+    }
+
+    @Test
+    public void clearAll_cacheEmpty_shouldBeNoOp() {
+        mLoader.clearAll();
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+    }
+
+    @Test
+    public void clearAll_inCache_shouldRemovePreview() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+
+        mLoader.clearAll();
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+    }
+
+    @Test
+    public void clearAll_inCache_multipleSizes_shouldRemoveAllSizes() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+        loadPreviewIntoCache(mWidgetItem, SIZE_20_20, BITMAP);
+
+        mLoader.clearAll();
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_20_20)).isFalse();
+    }
+
+    @Test
+    public void clearAll_inCache_multipleItems_shouldRemoveAll() {
+        loadPreviewIntoCache(mWidgetItem, SIZE_10_10, BITMAP);
+        loadPreviewIntoCache(mWidgetItem, SIZE_20_20, BITMAP);
+        loadPreviewIntoCache(mWidgetItem2, SIZE_20_20, BITMAP);
+
+        mLoader.clearAll();
+
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_10_10)).isFalse();
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem, SIZE_20_20)).isFalse();
+        assertThat(mLoader.isPreviewLoaded(mWidgetItem2, SIZE_20_20)).isFalse();
+    }
+
+    @Test
+    public void clearAll_loading_shouldCancelLoad() {
+        mLoader.loadPreview(mTestActivity, mWidgetItem, SIZE_10_10, mPreviewLoadedCallback);
+
+        mLoader.clearAll();
+
+        verify(mCancellationSignal).cancel();
+    }
+
+    private void loadPreviewIntoCache(WidgetItem widgetItem, Size size, Bitmap bitmap) {
+        reset(mDelegate);
+        mLoader.loadPreview(mTestActivity, widgetItem, size, ignored -> {});
+        verify(mDelegate).loadPreview(any(), any(), any(), mCallbackCaptor.capture());
+        WidgetPreviewLoadedCallback loaderCallback = mCallbackCaptor.getValue();
+
+        loaderCallback.onPreviewLoaded(bitmap);
+    }
+}
diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java
index cc36f63..c946c72 100644
--- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java
+++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsDiffReporterTest.java
@@ -207,10 +207,9 @@
         // GIVEN the current list has app headers [A, B, E content].
         ArrayList<WidgetsListBaseEntry> currentList = new ArrayList<>(
                 List.of(mHeaderA, mHeaderB, mContentE));
-        // GIVEN the new list has app headers [A, B, E content].
-        List<WidgetsListBaseEntry> newList = List.of(mHeaderA, mHeaderB, mContentE);
-        // GIVEN the user has interacted with B.
-        mHeaderB.setIsWidgetListShown(true);
+        // GIVEN the new list has app headers [A, B, E content] and the user has interacted with B.
+        List<WidgetsListBaseEntry> newList =
+                List.of(mHeaderA, mHeaderB.withWidgetListShown(), mContentE);
 
         // WHEN computing the list difference.
         mWidgetsDiffReporter.process(currentList, newList, COMPARATOR);
diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java
index e1214ff..c730fc0 100644
--- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java
+++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListAdapterTest.java
@@ -33,13 +33,13 @@
 import androidx.recyclerview.widget.RecyclerView;
 
 import com.android.launcher3.InvariantDeviceProfile;
-import com.android.launcher3.WidgetPreviewLoader;
 import com.android.launcher3.icons.BitmapInfo;
 import com.android.launcher3.icons.ComponentWithLabel;
 import com.android.launcher3.icons.IconCache;
 import com.android.launcher3.model.WidgetItem;
 import com.android.launcher3.model.data.PackageItemInfo;
 import com.android.launcher3.util.PackageUserKey;
+import com.android.launcher3.widget.DatabaseWidgetPreviewLoader;
 import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;
 import com.android.launcher3.widget.model.WidgetsListBaseEntry;
 import com.android.launcher3.widget.model.WidgetsListContentEntry;
@@ -64,7 +64,7 @@
     private static final String TEST_PACKAGE_PLACEHOLDER = "com.google.test";
 
     @Mock private LayoutInflater mMockLayoutInflater;
-    @Mock private WidgetPreviewLoader mMockWidgetCache;
+    @Mock private DatabaseWidgetPreviewLoader mMockWidgetCache;
     @Mock private RecyclerView.AdapterDataObserver mListener;
     @Mock private IconCache mIconCache;
 
diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java
index 4e2a508..81b0c5f 100644
--- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java
+++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListHeaderViewHolderBinderTest.java
@@ -34,7 +34,6 @@
 import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.InvariantDeviceProfile;
 import com.android.launcher3.R;
-import com.android.launcher3.WidgetPreviewLoader;
 import com.android.launcher3.icons.BitmapInfo;
 import com.android.launcher3.icons.ComponentWithLabel;
 import com.android.launcher3.icons.IconCache;
@@ -42,6 +41,7 @@
 import com.android.launcher3.model.data.PackageItemInfo;
 import com.android.launcher3.testing.TestActivity;
 import com.android.launcher3.util.PackageUserKey;
+import com.android.launcher3.widget.DatabaseWidgetPreviewLoader;
 import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;
 import com.android.launcher3.widget.model.WidgetsListHeaderEntry;
 
@@ -79,7 +79,7 @@
     @Mock
     private DeviceProfile mDeviceProfile;
     @Mock
-    private WidgetPreviewLoader mWidgetPreviewLoader;
+    private DatabaseWidgetPreviewLoader mWidgetPreviewLoader;
     @Mock
     private OnHeaderClickListener mOnHeaderClickListener;
 
diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinderTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinderTest.java
index d6aea55..a0ba7c3 100644
--- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinderTest.java
+++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListSearchHeaderViewHolderBinderTest.java
@@ -34,7 +34,6 @@
 import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.InvariantDeviceProfile;
 import com.android.launcher3.R;
-import com.android.launcher3.WidgetPreviewLoader;
 import com.android.launcher3.icons.BitmapInfo;
 import com.android.launcher3.icons.ComponentWithLabel;
 import com.android.launcher3.icons.IconCache;
@@ -42,6 +41,7 @@
 import com.android.launcher3.model.data.PackageItemInfo;
 import com.android.launcher3.testing.TestActivity;
 import com.android.launcher3.util.PackageUserKey;
+import com.android.launcher3.widget.DatabaseWidgetPreviewLoader;
 import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;
 import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry;
 
@@ -79,7 +79,7 @@
     @Mock
     private DeviceProfile mDeviceProfile;
     @Mock
-    private WidgetPreviewLoader mWidgetPreviewLoader;
+    private DatabaseWidgetPreviewLoader mWidgetPreviewLoader;
     @Mock
     private OnHeaderClickListener mOnHeaderClickListener;
 
diff --git a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinderTest.java b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinderTest.java
index 2f1326f..8f9d132 100644
--- a/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinderTest.java
+++ b/robolectric_tests/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinderTest.java
@@ -38,13 +38,14 @@
 import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.InvariantDeviceProfile;
 import com.android.launcher3.R;
-import com.android.launcher3.WidgetPreviewLoader;
 import com.android.launcher3.icons.BitmapInfo;
 import com.android.launcher3.icons.ComponentWithLabel;
 import com.android.launcher3.icons.IconCache;
 import com.android.launcher3.model.WidgetItem;
 import com.android.launcher3.model.data.PackageItemInfo;
 import com.android.launcher3.testing.TestActivity;
+import com.android.launcher3.widget.CachingWidgetPreviewLoader;
+import com.android.launcher3.widget.DatabaseWidgetPreviewLoader;
 import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;
 import com.android.launcher3.widget.WidgetCell;
 import com.android.launcher3.widget.model.WidgetsListContentEntry;
@@ -85,7 +86,7 @@
     @Mock
     private IconCache mIconCache;
     @Mock
-    private WidgetPreviewLoader mWidgetPreviewLoader;
+    private DatabaseWidgetPreviewLoader mWidgetPreviewLoader;
     @Mock
     private DeviceProfile mDeviceProfile;
 
@@ -113,11 +114,10 @@
                 /* iconClickListener= */ view -> {},
                 /* iconLongClickListener= */ view -> false);
         mViewHolderBinder = new WidgetsListTableViewHolderBinder(
-                mContext,
                 LayoutInflater.from(mTestActivity),
                 mOnIconClickListener,
                 mOnLongClickListener,
-                mWidgetPreviewLoader,
+                new CachingWidgetPreviewLoader(mWidgetPreviewLoader),
                 new WidgetsListDrawableFactory(mTestActivity),
                 widgetsListAdapter);
     }
diff --git a/src/com/android/launcher3/LauncherAppState.java b/src/com/android/launcher3/LauncherAppState.java
index b3d096c..3d6be69 100644
--- a/src/com/android/launcher3/LauncherAppState.java
+++ b/src/com/android/launcher3/LauncherAppState.java
@@ -48,6 +48,7 @@
 import com.android.launcher3.util.SettingsCache;
 import com.android.launcher3.util.SimpleBroadcastReceiver;
 import com.android.launcher3.util.Themes;
+import com.android.launcher3.widget.DatabaseWidgetPreviewLoader;
 import com.android.launcher3.widget.custom.CustomWidgetManager;
 
 public class LauncherAppState {
@@ -63,7 +64,7 @@
     private final LauncherModel mModel;
     private final IconProvider mIconProvider;
     private final IconCache mIconCache;
-    private final WidgetPreviewLoader mWidgetCache;
+    private final DatabaseWidgetPreviewLoader mWidgetCache;
     private final InvariantDeviceProfile mInvariantDeviceProfile;
     private final RunnableList mOnTerminateCallback = new RunnableList();
 
@@ -138,7 +139,7 @@
         mIconProvider =  new IconProvider(context, Themes.isThemedIconEnabled(context));
         mIconCache = new IconCache(mContext, mInvariantDeviceProfile,
                 iconCacheFileName, mIconProvider);
-        mWidgetCache = new WidgetPreviewLoader(mContext, mIconCache);
+        mWidgetCache = new DatabaseWidgetPreviewLoader(mContext, mIconCache);
         mModel = new LauncherModel(context, this, mIconCache, new AppFilter(mContext));
         mOnTerminateCallback.add(mIconCache::close);
     }
@@ -180,7 +181,7 @@
         return mModel;
     }
 
-    public WidgetPreviewLoader getWidgetCache() {
+    public DatabaseWidgetPreviewLoader getWidgetCache() {
         return mWidgetCache;
     }
 
diff --git a/src/com/android/launcher3/model/WidgetItem.java b/src/com/android/launcher3/model/WidgetItem.java
index 97071bb..7198d54 100644
--- a/src/com/android/launcher3/model/WidgetItem.java
+++ b/src/com/android/launcher3/model/WidgetItem.java
@@ -1,7 +1,11 @@
 package com.android.launcher3.model;
 
+import static com.android.launcher3.Utilities.ATLEAST_S;
+
+import android.annotation.SuppressLint;
 import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
+import android.content.res.Resources;
 
 import com.android.launcher3.InvariantDeviceProfile;
 import com.android.launcher3.Utilities;
@@ -59,4 +63,15 @@
         }
         return false;
     }
+
+    /** Returns whether this {@link WidgetItem} has a preview layout that can be used. */
+    @SuppressLint("NewApi") // Already added API check.
+    public boolean hasPreviewLayout() {
+        return ATLEAST_S && widgetInfo != null && widgetInfo.previewLayout != Resources.ID_NULL;
+    }
+
+    /** Returns whether this {@link WidgetItem} is for a shortcut rather than an app widget. */
+    public boolean isShortcut() {
+        return activityInfo != null;
+    }
 }
diff --git a/src/com/android/launcher3/widget/CachingWidgetPreviewLoader.java b/src/com/android/launcher3/widget/CachingWidgetPreviewLoader.java
new file mode 100644
index 0000000..afceadd
--- /dev/null
+++ b/src/com/android/launcher3/widget/CachingWidgetPreviewLoader.java
@@ -0,0 +1,289 @@
+/*
+ * Copyright (C) 2021 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.widget;
+
+import android.graphics.Bitmap;
+import android.os.CancellationSignal;
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.collection.ArrayMap;
+import androidx.collection.ArraySet;
+
+import com.android.launcher3.BaseActivity;
+import com.android.launcher3.model.WidgetItem;
+import com.android.launcher3.util.ComponentKey;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** Wrapper around {@link DatabaseWidgetPreviewLoader} that contains caching logic. */
+public class CachingWidgetPreviewLoader implements WidgetPreviewLoader {
+
+    @NonNull private final WidgetPreviewLoader mDelegate;
+    @NonNull private final Map<ComponentKey, Map<Size, CacheResult>> mCache = new ArrayMap<>();
+
+    public CachingWidgetPreviewLoader(@NonNull WidgetPreviewLoader delegate) {
+        mDelegate = delegate;
+    }
+
+    /** Returns whether the preview is loaded for the item and size. */
+    public boolean isPreviewLoaded(@NonNull WidgetItem item, @NonNull Size previewSize) {
+        return getPreview(item, previewSize) != null;
+    }
+
+    /** Returns the cached preview for the item and size, or null if there is none. */
+    @Nullable
+    public Bitmap getPreview(@NonNull WidgetItem item, @NonNull Size previewSize) {
+        CacheResult cacheResult = getCacheResult(item, previewSize);
+        if (cacheResult instanceof CacheResult.Loaded) {
+            return ((CacheResult.Loaded) cacheResult).mBitmap;
+        } else {
+            return null;
+        }
+    }
+
+    @NonNull
+    private CacheResult getCacheResult(@NonNull WidgetItem item, @NonNull Size previewSize) {
+        synchronized (mCache) {
+            Map<Size, CacheResult> cacheResults = mCache.get(toComponentKey(item));
+            if (cacheResults == null) {
+                return CacheResult.MISS;
+            }
+
+            return cacheResults.getOrDefault(previewSize, CacheResult.MISS);
+        }
+    }
+
+    /**
+     * Puts the result in the cache for the item and size. Returns the value previously in the
+     * cache, or null if there was none.
+     */
+    @Nullable
+    private CacheResult putCacheResult(
+            @NonNull WidgetItem item,
+            @NonNull Size previewSize,
+            @Nullable CacheResult cacheResult) {
+        ComponentKey key = toComponentKey(item);
+        synchronized (mCache) {
+            Map<Size, CacheResult> cacheResults = mCache.getOrDefault(key, new ArrayMap<>());
+            CacheResult previous;
+            if (cacheResult == null) {
+                previous = cacheResults.remove(previewSize);
+                if (cacheResults.isEmpty()) {
+                    mCache.remove(key);
+                } else {
+                    previous = cacheResults.put(previewSize, cacheResult);
+                    mCache.put(key, cacheResults);
+                }
+            } else {
+                previous = cacheResults.put(previewSize, cacheResult);
+                mCache.put(key, cacheResults);
+            }
+            return previous;
+        }
+    }
+
+    private void removeCacheResult(@NonNull WidgetItem item, @NonNull Size previewSize) {
+        ComponentKey key = toComponentKey(item);
+        synchronized (mCache) {
+            Map<Size, CacheResult> cacheResults = mCache.getOrDefault(key, new ArrayMap<>());
+            cacheResults.remove(previewSize);
+            mCache.put(key, cacheResults);
+        }
+    }
+
+    /**
+     * Gets the preview for the widget item and size, using the value in the cache if stored.
+     *
+     * @return a {@link CancellationSignal}, which can cancel the request before it loads
+     */
+    @Override
+    @UiThread
+    @NonNull
+    public CancellationSignal loadPreview(
+            @NonNull BaseActivity activity, @NonNull WidgetItem item, @NonNull Size previewSize,
+            @NonNull WidgetPreviewLoadedCallback callback) {
+        CancellationSignal signal = new CancellationSignal();
+        signal.setOnCancelListener(() -> {
+            synchronized (mCache) {
+                CacheResult cacheResult = getCacheResult(item, previewSize);
+                if (!(cacheResult instanceof CacheResult.Loading)) {
+                    // If the key isn't actively loading, then this is a no-op. Cancelling loading
+                    // shouldn't clear the cache if we've already loaded.
+                    return;
+                }
+
+                CacheResult.Loading prev = (CacheResult.Loading) cacheResult;
+                CacheResult.Loading updated = prev.withoutCallback(callback);
+
+                if (updated.mCallbacks.isEmpty()) {
+                    // If the last callback was removed, then cancel the underlying request in the
+                    // delegate.
+                    prev.mCancellationSignal.cancel();
+                    removeCacheResult(item, previewSize);
+                } else {
+                    // If there are other callbacks still active, then don't cancel the delegate's
+                    // request, just remove this callback from the set.
+                    putCacheResult(item, previewSize, updated);
+                }
+            }
+        });
+
+        synchronized (mCache) {
+            CacheResult cacheResult = getCacheResult(item, previewSize);
+            if (cacheResult instanceof CacheResult.Loaded) {
+                // If the bitmap is already present in the cache, invoke the callback immediately.
+                callback.onPreviewLoaded(((CacheResult.Loaded) cacheResult).mBitmap);
+                return signal;
+            }
+
+            if (cacheResult instanceof CacheResult.Loading) {
+                // If we're already loading the preview for this key, then just add the callback
+                // to the set we'll call after it loads.
+                CacheResult.Loading prev = (CacheResult.Loading) cacheResult;
+                putCacheResult(item, previewSize, prev.withCallback(callback));
+                return signal;
+            }
+
+            CancellationSignal delegateCancellationSignal =
+                    mDelegate.loadPreview(
+                            activity,
+                            item,
+                            previewSize,
+                            preview -> {
+                                CacheResult prev;
+                                synchronized (mCache) {
+                                    prev = putCacheResult(
+                                            item, previewSize, new CacheResult.Loaded(preview));
+                                }
+                                if (prev instanceof CacheResult.Loading) {
+                                    // Notify each stored callback that the preview has loaded.
+                                    ((CacheResult.Loading) prev).mCallbacks
+                                            .forEach(c -> c.onPreviewLoaded(preview));
+                                } else {
+                                    // If there isn't a loading object in the cache, then we were
+                                    // notified before adding this signal to the cache. Just
+                                    // call back to the provided callback, there can't be others.
+                                    callback.onPreviewLoaded(preview);
+                                }
+                            });
+            ArraySet<WidgetPreviewLoadedCallback> callbacks = new ArraySet<>();
+            callbacks.add(callback);
+            putCacheResult(
+                    item,
+                    previewSize,
+                    new CacheResult.Loading(delegateCancellationSignal, callbacks));
+        }
+
+        return signal;
+    }
+
+    /** Clears all cached previews for {@code items}, cancelling any in-progress preview loading. */
+    public void clearPreviews(Iterable<WidgetItem> items) {
+        List<CacheResult> previousCacheResults = new ArrayList<>();
+        synchronized (mCache) {
+            for (WidgetItem item : items) {
+                Map<Size, CacheResult> previousMap = mCache.remove(toComponentKey(item));
+                if (previousMap != null) {
+                    previousCacheResults.addAll(previousMap.values());
+                }
+            }
+        }
+
+        for (CacheResult previousCacheResult : previousCacheResults) {
+            if (previousCacheResult instanceof CacheResult.Loading) {
+                ((CacheResult.Loading) previousCacheResult).mCancellationSignal.cancel();
+            }
+        }
+    }
+
+    /** Clears all cached previews, cancelling any in-progress preview loading. */
+    public void clearAll() {
+        List<CacheResult> previousCacheResults;
+        synchronized (mCache) {
+            previousCacheResults =
+                    mCache
+                    .values()
+                    .stream()
+                    .flatMap(sizeToResult -> sizeToResult.values().stream())
+                    .collect(Collectors.toList());
+            mCache.clear();
+        }
+
+        for (CacheResult previousCacheResult : previousCacheResults) {
+            if (previousCacheResult instanceof CacheResult.Loading) {
+                ((CacheResult.Loading) previousCacheResult).mCancellationSignal.cancel();
+            }
+        }
+    }
+
+    private abstract static class CacheResult {
+        static final CacheResult MISS = new CacheResult() {};
+
+        static final class Loading extends CacheResult {
+            @NonNull final CancellationSignal mCancellationSignal;
+            @NonNull final Set<WidgetPreviewLoadedCallback> mCallbacks;
+
+            Loading(@NonNull CancellationSignal cancellationSignal,
+                    @NonNull Set<WidgetPreviewLoadedCallback> callbacks) {
+                mCancellationSignal = cancellationSignal;
+                mCallbacks = callbacks;
+            }
+
+            @NonNull
+            Loading withCallback(@NonNull WidgetPreviewLoadedCallback callback) {
+                if (mCallbacks.contains(callback)) return this;
+                Set<WidgetPreviewLoadedCallback> newCallbacks =
+                        new ArraySet<>(mCallbacks.size() + 1);
+                newCallbacks.addAll(mCallbacks);
+                newCallbacks.add(callback);
+                return new Loading(mCancellationSignal, newCallbacks);
+            }
+
+            @NonNull
+            Loading withoutCallback(@NonNull WidgetPreviewLoadedCallback callback) {
+                if (!mCallbacks.contains(callback)) return this;
+                Set<WidgetPreviewLoadedCallback> newCallbacks =
+                        new ArraySet<>(mCallbacks.size() - 1);
+                for (WidgetPreviewLoadedCallback existingCallback : mCallbacks) {
+                    if (!existingCallback.equals(callback)) {
+                        newCallbacks.add(existingCallback);
+                    }
+                }
+                return new Loading(mCancellationSignal, newCallbacks);
+            }
+        }
+
+        static final class Loaded extends CacheResult {
+            @NonNull final Bitmap mBitmap;
+
+            Loaded(@NonNull Bitmap bitmap) {
+                mBitmap = bitmap;
+            }
+        }
+    }
+
+    @NonNull
+    private static ComponentKey toComponentKey(@NonNull WidgetItem item) {
+        return new ComponentKey(item.componentName, item.user);
+    }
+}
diff --git a/src/com/android/launcher3/WidgetPreviewLoader.java b/src/com/android/launcher3/widget/DatabaseWidgetPreviewLoader.java
similarity index 86%
rename from src/com/android/launcher3/WidgetPreviewLoader.java
rename to src/com/android/launcher3/widget/DatabaseWidgetPreviewLoader.java
index ff3584a..6de3e11 100644
--- a/src/com/android/launcher3/WidgetPreviewLoader.java
+++ b/src/com/android/launcher3/widget/DatabaseWidgetPreviewLoader.java
@@ -1,4 +1,19 @@
-package com.android.launcher3;
+/*
+ * Copyright (C) 2021 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.widget;
 
 import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
 import static com.android.launcher3.util.Executors.MODEL_EXECUTOR;
@@ -32,8 +47,14 @@
 import android.util.Pair;
 import android.util.Size;
 
+import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+import com.android.launcher3.BaseActivity;
+import com.android.launcher3.DeviceProfile;
+import com.android.launcher3.LauncherFiles;
+import com.android.launcher3.R;
+import com.android.launcher3.Utilities;
 import com.android.launcher3.icons.GraphicsUtils;
 import com.android.launcher3.icons.IconCache;
 import com.android.launcher3.icons.LauncherIcons;
@@ -47,9 +68,6 @@
 import com.android.launcher3.util.Preconditions;
 import com.android.launcher3.util.SQLiteCacheHelper;
 import com.android.launcher3.util.Thunk;
-import com.android.launcher3.widget.LauncherAppWidgetProviderInfo;
-import com.android.launcher3.widget.WidgetCell;
-import com.android.launcher3.widget.WidgetManagerHelper;
 import com.android.launcher3.widget.util.WidgetSizes;
 
 import java.util.ArrayList;
@@ -60,7 +78,8 @@
 import java.util.WeakHashMap;
 import java.util.concurrent.ExecutionException;
 
-public class WidgetPreviewLoader {
+/** {@link WidgetPreviewLoader} that loads preview images from a {@link CacheDb}. */
+public class DatabaseWidgetPreviewLoader implements WidgetPreviewLoader {
 
     private static final String TAG = "WidgetPreviewLoader";
     private static final boolean DEBUG = false;
@@ -80,7 +99,7 @@
     private final UserCache mUserCache;
     private final CacheDb mDb;
 
-    public WidgetPreviewLoader(Context context, IconCache iconCache) {
+    public DatabaseWidgetPreviewLoader(Context context, IconCache iconCache) {
         mContext = context;
         mIconCache = iconCache;
         mUserCache = UserCache.INSTANCE.get(context);
@@ -89,16 +108,24 @@
 
     /**
      * Generates the widget preview on {@link AsyncTask#THREAD_POOL_EXECUTOR}. Must be
-     * called on UI thread
+     * called on UI thread.
      *
      * @return a request id which can be used to cancel the request.
      */
-    public CancellationSignal getPreview(WidgetItem item, int previewWidth,
-            int previewHeight, WidgetCell caller) {
+    @Override
+    @NonNull
+    public CancellationSignal loadPreview(
+            @NonNull BaseActivity activity,
+            @NonNull WidgetItem item,
+            @NonNull Size previewSize,
+            @NonNull WidgetPreviewLoadedCallback callback) {
+        int previewWidth = previewSize.getWidth();
+        int previewHeight = previewSize.getHeight();
         String size = previewWidth + "x" + previewHeight;
         WidgetCacheKey key = new WidgetCacheKey(item.componentName, item.user, size);
 
-        PreviewLoadTask task = new PreviewLoadTask(key, item, previewWidth, previewHeight, caller);
+        PreviewLoadTask task =
+                new PreviewLoadTask(activity, key, item, previewWidth, previewHeight, callback);
         task.executeOnExecutor(Executors.THREAD_POOL_EXECUTOR);
 
         CancellationSignal signal = new CancellationSignal();
@@ -106,6 +133,7 @@
         return signal;
     }
 
+    /** Clears the database storing previews. */
     public void refresh() {
         mDb.clear();
     }
@@ -126,21 +154,37 @@
         private static final String COLUMN_VERSION = "version";
         private static final String COLUMN_PREVIEW_BITMAP = "preview_bitmap";
 
-        public CacheDb(Context context) {
+        CacheDb(Context context) {
             super(context, LauncherFiles.WIDGET_PREVIEWS_DB, DB_VERSION, TABLE_NAME);
         }
 
         @Override
         public void onCreateTable(SQLiteDatabase database) {
-            database.execSQL("CREATE TABLE IF NOT EXISTS " + TABLE_NAME + " (" +
-                    COLUMN_COMPONENT + " TEXT NOT NULL, " +
-                    COLUMN_USER + " INTEGER NOT NULL, " +
-                    COLUMN_SIZE + " TEXT NOT NULL, " +
-                    COLUMN_PACKAGE + " TEXT NOT NULL, " +
-                    COLUMN_LAST_UPDATED + " INTEGER NOT NULL DEFAULT 0, " +
-                    COLUMN_VERSION + " INTEGER NOT NULL DEFAULT 0, " +
-                    COLUMN_PREVIEW_BITMAP + " BLOB, " +
-                    "PRIMARY KEY (" + COLUMN_COMPONENT + ", " + COLUMN_USER + ", " + COLUMN_SIZE + ") " +
+            database.execSQL("CREATE TABLE IF NOT EXISTS "
+                    + TABLE_NAME
+                    + " ("
+                    + COLUMN_COMPONENT
+                    + " TEXT NOT NULL, "
+                    + COLUMN_USER
+                    + " INTEGER NOT NULL, "
+                    + COLUMN_SIZE
+                    + " TEXT NOT NULL, "
+                    + COLUMN_PACKAGE
+                    + " TEXT NOT NULL, "
+                    + COLUMN_LAST_UPDATED
+                    + " INTEGER NOT NULL DEFAULT 0, "
+                    + COLUMN_VERSION
+                    + " INTEGER NOT NULL DEFAULT 0, "
+                    + COLUMN_PREVIEW_BITMAP
+                    + " BLOB, "
+                    + "PRIMARY KEY ("
+                    + COLUMN_COMPONENT
+                    + ", "
+                    + COLUMN_USER
+                    + ", "
+                    + COLUMN_SIZE
+                    + ") "
+                    +
                     ");");
         }
     }
@@ -149,7 +193,7 @@
         ContentValues values = new ContentValues();
         values.put(CacheDb.COLUMN_COMPONENT, key.componentName.flattenToShortString());
         values.put(CacheDb.COLUMN_USER, mUserCache.getSerialNumberForUser(key.user));
-        values.put(CacheDb.COLUMN_SIZE, key.size);
+        values.put(CacheDb.COLUMN_SIZE, key.mSize);
         values.put(CacheDb.COLUMN_PACKAGE, key.componentName.getPackageName());
         values.put(CacheDb.COLUMN_VERSION, versions[0]);
         values.put(CacheDb.COLUMN_LAST_UPDATED, versions[1]);
@@ -157,12 +201,14 @@
         mDb.insertOrReplace(values);
     }
 
+    /** Removes the package from the preview database. */
     public void removePackage(String packageName, UserHandle user) {
         removePackage(packageName, user, mUserCache.getSerialNumberForUser(user));
     }
 
-    private void removePackage(String packageName, UserHandle user, long userSerial) {
-        synchronized(mPackageVersions) {
+    /** Removes the package from the preview database. */
+    public void removePackage(String packageName, UserHandle user, long userSerial) {
+        synchronized (mPackageVersions) {
             mPackageVersions.remove(packageName);
         }
 
@@ -264,7 +310,7 @@
                     new String[]{
                             key.componentName.flattenToShortString(),
                             Long.toString(mUserCache.getSerialNumberForUser(key.user)),
-                            key.size
+                            key.mSize
                     });
             // If cancelled, skip getting the blob and decoding it into a bitmap
             if (loadTask.isCancelled()) {
@@ -293,7 +339,7 @@
     }
 
     /**
-     * Returns generatedPreview for a widget and if the preview should be saved in persistent
+     * Returns a generated preview for a widget and if the preview should be saved in persistent
      * storage.
      * @param launcher
      * @param item
@@ -344,8 +390,10 @@
             if (drawable != null) {
                 drawable = mutateOnMainThread(drawable);
             } else {
-                Log.w(TAG, "Can't load widget preview drawable 0x" +
-                        Integer.toHexString(info.previewImage) + " for provider: " + info.provider);
+                Log.w(TAG, "Can't load widget preview drawable 0x"
+                        + Integer.toHexString(info.previewImage)
+                        + " for provider: "
+                        + info.provider);
             }
         }
 
@@ -379,8 +427,8 @@
             scale = maxPreviewWidth / (float) (previewWidth);
         }
         if (scale != 1f) {
-            previewWidth = Math.max((int)(scale * previewWidth), 1);
-            previewHeight = Math.max((int)(scale * previewHeight), 1);
+            previewWidth = Math.max((int) (scale * previewWidth), 1);
+            previewHeight = Math.max((int) (scale * previewHeight), 1);
         }
 
         final Canvas c = new Canvas();
@@ -554,13 +602,13 @@
         }
     }
 
-    public class PreviewLoadTask extends AsyncTask<Void, Void, Bitmap>
+    private class PreviewLoadTask extends AsyncTask<Void, Void, Bitmap>
             implements CancellationSignal.OnCancelListener {
         @Thunk final WidgetCacheKey mKey;
         private final WidgetItem mInfo;
         private final int mPreviewHeight;
         private final int mPreviewWidth;
-        private final WidgetCell mCaller;
+        private final WidgetPreviewLoadedCallback mCallback;
         private final BaseActivity mActivity;
         @Thunk long[] mVersions;
         @Thunk Bitmap mBitmapToRecycle;
@@ -568,14 +616,14 @@
         @Nullable private Bitmap mUnusedPreviewBitmap;
         private boolean mSaveToDB = false;
 
-        PreviewLoadTask(WidgetCacheKey key, WidgetItem info, int previewWidth,
-                int previewHeight, WidgetCell caller) {
+        PreviewLoadTask(BaseActivity activity, WidgetCacheKey key, WidgetItem info,
+                int previewWidth, int previewHeight, WidgetPreviewLoadedCallback callback) {
+            mActivity = activity;
             mKey = key;
             mInfo = info;
             mPreviewHeight = previewHeight;
             mPreviewWidth = previewWidth;
-            mCaller = caller;
-            mActivity = BaseActivity.fromContext(mCaller.getContext());
+            mCallback = callback;
             if (DEBUG) {
                 Log.d(TAG, String.format("%s, %s, %d, %d",
                         mKey, mInfo, mPreviewHeight, mPreviewWidth));
@@ -593,9 +641,9 @@
             synchronized (mUnusedBitmaps) {
                 // Check if we can re-use a bitmap
                 for (Bitmap candidate : mUnusedBitmaps) {
-                    if (candidate != null && candidate.isMutable() &&
-                            candidate.getWidth() == mPreviewWidth &&
-                            candidate.getHeight() == mPreviewHeight) {
+                    if (candidate != null && candidate.isMutable()
+                            && candidate.getWidth() == mPreviewWidth
+                            && candidate.getHeight() == mPreviewHeight) {
                         unusedBitmap = candidate;
                         mUnusedBitmaps.remove(unusedBitmap);
                         break;
@@ -638,7 +686,7 @@
 
         @Override
         protected void onPostExecute(final Bitmap preview) {
-            mCaller.applyPreview(preview);
+            mCallback.onPreviewLoaded(preview);
 
             // Write the generated preview to the DB in the worker thread
             if (mVersions != null) {
@@ -716,21 +764,21 @@
 
     private static final class WidgetCacheKey extends ComponentKey {
 
-        @Thunk final String size;
+        @Thunk final String mSize;
 
-        public WidgetCacheKey(ComponentName componentName, UserHandle user, String size) {
+        WidgetCacheKey(ComponentName componentName, UserHandle user, String size) {
             super(componentName, user);
-            this.size = size;
+            this.mSize = size;
         }
 
         @Override
         public int hashCode() {
-            return super.hashCode() ^ size.hashCode();
+            return super.hashCode() ^ mSize.hashCode();
         }
 
         @Override
         public boolean equals(Object o) {
-            return super.equals(o) && ((WidgetCacheKey) o).size.equals(size);
+            return super.equals(o) && ((WidgetCacheKey) o).mSize.equals(mSize);
         }
     }
 }
diff --git a/src/com/android/launcher3/widget/WidgetCell.java b/src/com/android/launcher3/widget/WidgetCell.java
index b1ccfd9..91529be 100644
--- a/src/com/android/launcher3/widget/WidgetCell.java
+++ b/src/com/android/launcher3/widget/WidgetCell.java
@@ -19,7 +19,6 @@
 import static com.android.launcher3.Utilities.ATLEAST_S;
 
 import android.content.Context;
-import android.content.res.Resources;
 import android.graphics.Bitmap;
 import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
@@ -44,7 +43,6 @@
 import com.android.launcher3.CheckLongPressHelper;
 import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.R;
-import com.android.launcher3.WidgetPreviewLoader;
 import com.android.launcher3.icons.FastBitmapDrawable;
 import com.android.launcher3.icons.RoundDrawableWrapper;
 import com.android.launcher3.model.WidgetItem;
@@ -222,21 +220,18 @@
             return;
         }
 
-        if (ATLEAST_S
-                && mRemoteViewsPreview == null
-                && item.widgetInfo != null
-                && item.widgetInfo.previewLayout != Resources.ID_NULL) {
-            mAppWidgetHostViewPreview = new LauncherAppWidgetHostView(getContext());
-            LauncherAppWidgetProviderInfo launcherAppWidgetProviderInfo =
-                    LauncherAppWidgetProviderInfo.fromProviderInfo(getContext(),
-                            item.widgetInfo.clone());
-            // A hack to force the initial layout to be the preview layout since there is no API for
-            // rendering a preview layout for work profile apps yet. For non-work profile layout, a
-            // proper solution is to use RemoteViews(PackageName, LayoutId).
-            launcherAppWidgetProviderInfo.initialLayout = item.widgetInfo.previewLayout;
-            setAppWidgetHostViewPreview(mAppWidgetHostViewPreview,
-                    launcherAppWidgetProviderInfo, /* remoteViews= */ null);
-        }
+        if (!item.hasPreviewLayout()) return;
+
+        mAppWidgetHostViewPreview = new LauncherAppWidgetHostView(getContext());
+        LauncherAppWidgetProviderInfo launcherAppWidgetProviderInfo =
+                LauncherAppWidgetProviderInfo.fromProviderInfo(getContext(),
+                        item.widgetInfo.clone());
+        // A hack to force the initial layout to be the preview layout since there is no API for
+        // rendering a preview layout for work profile apps yet. For non-work profile layout, a
+        // proper solution is to use RemoteViews(PackageName, LayoutId).
+        launcherAppWidgetProviderInfo.initialLayout = item.widgetInfo.previewLayout;
+        setAppWidgetHostViewPreview(mAppWidgetHostViewPreview,
+                launcherAppWidgetProviderInfo, /* remoteViews= */ null);
     }
 
     private void setAppWidgetHostViewPreview(
@@ -344,22 +339,25 @@
         if (mActiveRequest != null) {
             return;
         }
-        mActiveRequest = mWidgetPreviewLoader.getPreview(mItem, mPreviewWidth, mPreviewHeight,
-                this);
+        mActiveRequest = mWidgetPreviewLoader.loadPreview(
+                BaseActivity.fromContext(getContext()), mItem,
+                new Size(mPreviewWidth, mPreviewHeight),
+                this::applyPreview);
     }
 
     /** Sets the widget preview image size in number of cells. */
-    public void setPreviewSize(int spanX, int spanY) {
-        setPreviewSize(spanX, spanY, 1f);
+    public Size setPreviewSize(int spanX, int spanY) {
+        return setPreviewSize(spanX, spanY, 1f);
     }
 
     /** Sets the widget preview image size, in number of cells, and preview scale. */
-    public void setPreviewSize(int spanX, int spanY, float previewScale) {
+    public Size setPreviewSize(int spanX, int spanY, float previewScale) {
         DeviceProfile deviceProfile = mActivity.getDeviceProfile();
         Size widgetSize = WidgetSizes.getWidgetSizePx(deviceProfile, spanX, spanY);
         mPreviewWidth = widgetSize.getWidth();
         mPreviewHeight = widgetSize.getHeight();
         mPreviewScale = previewScale;
+        return widgetSize;
     }
 
     @Override
diff --git a/src/com/android/launcher3/widget/WidgetPreviewLoader.java b/src/com/android/launcher3/widget/WidgetPreviewLoader.java
new file mode 100644
index 0000000..ff5c82f
--- /dev/null
+++ b/src/com/android/launcher3/widget/WidgetPreviewLoader.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2021 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.widget;
+
+import android.graphics.Bitmap;
+import android.os.CancellationSignal;
+import android.util.Size;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+
+import com.android.launcher3.BaseActivity;
+import com.android.launcher3.model.WidgetItem;
+
+/** Asynchronous loader of preview bitmaps for {@link WidgetItem}s. */
+public interface WidgetPreviewLoader {
+    /**
+     * Loads a widget preview and calls back to {@code callback} when complete.
+     *
+     * @return a {@link CancellationSignal} which can be used to cancel the request.
+     */
+    @NonNull
+    @UiThread
+    CancellationSignal loadPreview(
+            @NonNull BaseActivity activity,
+            @NonNull WidgetItem item,
+            @NonNull Size previewSize,
+            @NonNull WidgetPreviewLoadedCallback callback);
+
+    /** Callback class for requests to {@link WidgetPreviewLoader}. */
+    interface WidgetPreviewLoadedCallback {
+        void onPreviewLoaded(@NonNull Bitmap preview);
+    }
+}
diff --git a/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java b/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java
index 73bae6f..abc79ff 100644
--- a/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java
+++ b/src/com/android/launcher3/widget/model/WidgetsListBaseEntry.java
@@ -59,6 +59,19 @@
     @Rank
     public abstract int getRank();
 
+    /**
+     * Marker interface for subclasses that are headers for widget list items.
+     *
+     * @param <T> The type of this class.
+     */
+    public interface Header<T extends WidgetsListBaseEntry & Header<T>> {
+        /** Returns whether the widget list is currently expanded. */
+        boolean isWidgetListShown();
+
+        /** Returns a copy of the item with the widget list shown. */
+        T withWidgetListShown();
+    }
+
     @Retention(SOURCE)
     @IntDef({RANK_WIDGETS_LIST_HEADER, RANK_WIDGETS_LIST_SEARCH_HEADER, RANK_WIDGETS_LIST_CONTENT})
     public @interface Rank {
diff --git a/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java b/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java
index 1fdc399..5b3ea94 100644
--- a/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java
+++ b/src/com/android/launcher3/widget/model/WidgetsListHeaderEntry.java
@@ -21,41 +21,33 @@
 import java.util.List;
 
 /** An information holder for an app which has widgets or/and shortcuts. */
-public final class WidgetsListHeaderEntry extends WidgetsListBaseEntry {
+public final class WidgetsListHeaderEntry extends WidgetsListBaseEntry
+        implements WidgetsListBaseEntry.Header<WidgetsListHeaderEntry> {
 
     public final int widgetsCount;
     public final int shortcutsCount;
 
-    private boolean mIsWidgetListShown = false;
-    private boolean mHasEntryUpdated = false;
+    private final boolean mIsWidgetListShown;
 
     public WidgetsListHeaderEntry(PackageItemInfo pkgItem, String titleSectionName,
             List<WidgetItem> items) {
+        this(pkgItem, titleSectionName, items, /* isWidgetListShown= */ false);
+    }
+
+    private WidgetsListHeaderEntry(PackageItemInfo pkgItem, String titleSectionName,
+            List<WidgetItem> items, boolean isWidgetListShown) {
         super(pkgItem, titleSectionName, items);
         widgetsCount = (int) items.stream().filter(item -> item.widgetInfo != null).count();
         shortcutsCount = Math.max(0, items.size() - widgetsCount);
-    }
-
-    /** Sets if the widgets list associated with this header is shown. */
-    public void setIsWidgetListShown(boolean isWidgetListShown) {
-        if (mIsWidgetListShown != isWidgetListShown) {
-            this.mIsWidgetListShown = isWidgetListShown;
-            mHasEntryUpdated = true;
-        } else {
-            mHasEntryUpdated = false;
-        }
+        mIsWidgetListShown = isWidgetListShown;
     }
 
     /** Returns {@code true} if the widgets list associated with this header is shown. */
+    @Override
     public boolean isWidgetListShown() {
         return mIsWidgetListShown;
     }
 
-    /** Returns {@code true} if this entry has been updated due to user interactions. */
-    public boolean hasEntryUpdated() {
-        return mHasEntryUpdated;
-    }
-
     @Override
     public String toString() {
         return "Header:" + mPkgItem.packageName + ":" + mWidgets.size();
@@ -72,6 +64,18 @@
         if (!(obj instanceof WidgetsListHeaderEntry)) return false;
         WidgetsListHeaderEntry otherEntry = (WidgetsListHeaderEntry) obj;
         return mWidgets.equals(otherEntry.mWidgets) && mPkgItem.equals(otherEntry.mPkgItem)
-                && mTitleSectionName.equals(otherEntry.mTitleSectionName);
+                && mTitleSectionName.equals(otherEntry.mTitleSectionName)
+                && mIsWidgetListShown == otherEntry.mIsWidgetListShown;
+    }
+
+    /** Returns a copy of this {@link WidgetsListHeaderEntry} with the widget list shown. */
+    @Override
+    public WidgetsListHeaderEntry withWidgetListShown() {
+        if (mIsWidgetListShown) return this;
+        return new WidgetsListHeaderEntry(
+                mPkgItem,
+                mTitleSectionName,
+                mWidgets,
+                /* isWidgetListShown= */ true);
     }
 }
diff --git a/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java b/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java
index 2aec3f8..c0f89bc 100644
--- a/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java
+++ b/src/com/android/launcher3/widget/model/WidgetsListSearchHeaderEntry.java
@@ -21,36 +21,28 @@
 import java.util.List;
 
 /** An information holder for an app which has widgets or/and shortcuts, to be shown in search. */
-public final class WidgetsListSearchHeaderEntry extends WidgetsListBaseEntry {
+public final class WidgetsListSearchHeaderEntry extends WidgetsListBaseEntry
+        implements WidgetsListBaseEntry.Header<WidgetsListSearchHeaderEntry> {
 
-    private boolean mIsWidgetListShown = false;
-    private boolean mHasEntryUpdated = false;
+    private final boolean mIsWidgetListShown;
 
     public WidgetsListSearchHeaderEntry(PackageItemInfo pkgItem, String titleSectionName,
             List<WidgetItem> items) {
-        super(pkgItem, titleSectionName, items);
+        this(pkgItem, titleSectionName, items, /* isWidgetListShown= */ false);
     }
 
-    /** Sets if the widgets list associated with this header is shown. */
-    public void setIsWidgetListShown(boolean isWidgetListShown) {
-        if (mIsWidgetListShown != isWidgetListShown) {
-            this.mIsWidgetListShown = isWidgetListShown;
-            mHasEntryUpdated = true;
-        } else {
-            mHasEntryUpdated = false;
-        }
+    private WidgetsListSearchHeaderEntry(PackageItemInfo pkgItem, String titleSectionName,
+            List<WidgetItem> items, boolean isWidgetListShown) {
+        super(pkgItem, titleSectionName, items);
+        mIsWidgetListShown = isWidgetListShown;
     }
 
     /** Returns {@code true} if the widgets list associated with this header is shown. */
+    @Override
     public boolean isWidgetListShown() {
         return mIsWidgetListShown;
     }
 
-    /** Returns {@code true} if this entry has been updated due to user interactions. */
-    public boolean hasEntryUpdated() {
-        return mHasEntryUpdated;
-    }
-
     @Override
     public String toString() {
         return "SearchHeader:" + mPkgItem.packageName + ":" + mWidgets.size();
@@ -67,6 +59,18 @@
         if (!(obj instanceof WidgetsListSearchHeaderEntry)) return false;
         WidgetsListSearchHeaderEntry otherEntry = (WidgetsListSearchHeaderEntry) obj;
         return mWidgets.equals(otherEntry.mWidgets) && mPkgItem.equals(otherEntry.mPkgItem)
-                && mTitleSectionName.equals(otherEntry.mTitleSectionName);
+                && mTitleSectionName.equals(otherEntry.mTitleSectionName)
+                && mIsWidgetListShown;
+    }
+
+    /** Returns a copy of this {@link WidgetsListSearchHeaderEntry} with the widget list shown. */
+    @Override
+    public WidgetsListSearchHeaderEntry withWidgetListShown() {
+        if (mIsWidgetListShown) return this;
+        return new WidgetsListSearchHeaderEntry(
+                mPkgItem,
+                mTitleSectionName,
+                mWidgets,
+                /* isWidgetListShown= */ true);
     }
 }
diff --git a/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java b/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java
index 42896ba..dfe447a 100644
--- a/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java
+++ b/src/com/android/launcher3/widget/picker/WidgetsDiffReporter.java
@@ -177,7 +177,7 @@
      */
     private boolean hasHeaderUpdated(WidgetsListBaseEntry curRow, WidgetsListBaseEntry newRow) {
         if (newRow instanceof WidgetsListHeaderEntry && curRow instanceof WidgetsListHeaderEntry) {
-            return ((WidgetsListHeaderEntry) newRow).hasEntryUpdated() || !curRow.equals(newRow);
+            return !curRow.equals(newRow);
         }
         if (newRow instanceof WidgetsListSearchHeaderEntry
                 && curRow instanceof WidgetsListSearchHeaderEntry) {
diff --git a/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java b/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java
index 6863c60..5d9adf0 100644
--- a/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java
+++ b/src/com/android/launcher3/widget/picker/WidgetsListAdapter.java
@@ -20,6 +20,7 @@
 import android.content.Context;
 import android.os.Process;
 import android.util.Log;
+import android.util.Size;
 import android.util.SparseArray;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -35,19 +36,25 @@
 import androidx.recyclerview.widget.RecyclerView.Adapter;
 import androidx.recyclerview.widget.RecyclerView.ViewHolder;
 
+import com.android.launcher3.BaseActivity;
+import com.android.launcher3.DeviceProfile;
 import com.android.launcher3.Launcher;
 import com.android.launcher3.R;
-import com.android.launcher3.WidgetPreviewLoader;
 import com.android.launcher3.icons.IconCache;
+import com.android.launcher3.model.WidgetItem;
 import com.android.launcher3.model.data.PackageItemInfo;
 import com.android.launcher3.recyclerview.ViewHolderBinder;
 import com.android.launcher3.util.LabelComparator;
 import com.android.launcher3.util.PackageUserKey;
+import com.android.launcher3.widget.CachingWidgetPreviewLoader;
+import com.android.launcher3.widget.DatabaseWidgetPreviewLoader;
 import com.android.launcher3.widget.WidgetCell;
+import com.android.launcher3.widget.WidgetPreviewLoader.WidgetPreviewLoadedCallback;
 import com.android.launcher3.widget.model.WidgetsListBaseEntry;
 import com.android.launcher3.widget.model.WidgetsListContentEntry;
 import com.android.launcher3.widget.model.WidgetsListHeaderEntry;
 import com.android.launcher3.widget.model.WidgetsListSearchHeaderEntry;
+import com.android.launcher3.widget.util.WidgetSizes;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -79,7 +86,9 @@
     private static final int VIEW_TYPE_WIDGETS_HEADER = R.id.view_type_widgets_header;
     private static final int VIEW_TYPE_WIDGETS_SEARCH_HEADER = R.id.view_type_widgets_search_header;
 
+    private final Context mContext;
     private final Launcher mLauncher;
+    private final CachingWidgetPreviewLoader mCachingPreviewLoader;
     private final WidgetsDiffReporter mDiffReporter;
     private final SparseArray<ViewHolderBinder> mViewHolderBinders = new SparseArray<>();
     private final WidgetsListTableViewHolderBinder mWidgetsListTableViewHolderBinder;
@@ -97,16 +106,23 @@
                             .equals(mWidgetsContentVisiblePackageUserKey);
     @Nullable private Predicate<WidgetsListBaseEntry> mFilter = null;
     @Nullable private RecyclerView mRecyclerView;
+    @Nullable private PackageUserKey mPendingClickHeader;
+    private int mShortcutPreviewPadding;
+
+    private final WidgetPreviewLoadedCallback mPreviewLoadedCallback =
+            ignored -> updateVisibleEntries();
 
     public WidgetsListAdapter(Context context, LayoutInflater layoutInflater,
-            WidgetPreviewLoader widgetPreviewLoader, IconCache iconCache,
+            DatabaseWidgetPreviewLoader widgetPreviewLoader, IconCache iconCache,
             OnClickListener iconClickListener, OnLongClickListener iconLongClickListener) {
+        mContext = context;
         mLauncher = Launcher.getLauncher(context);
+        mCachingPreviewLoader = new CachingWidgetPreviewLoader(widgetPreviewLoader);
         mDiffReporter = new WidgetsDiffReporter(iconCache, this);
         WidgetsListDrawableFactory listDrawableFactory = new WidgetsListDrawableFactory(context);
-        mWidgetsListTableViewHolderBinder = new WidgetsListTableViewHolderBinder(context,
+        mWidgetsListTableViewHolderBinder = new WidgetsListTableViewHolderBinder(
                 layoutInflater, iconClickListener, iconLongClickListener,
-                widgetPreviewLoader, listDrawableFactory, /* listAdapter= */ this);
+                mCachingPreviewLoader, listDrawableFactory, /* listAdapter= */ this);
         mViewHolderBinders.put(VIEW_TYPE_WIDGETS_LIST, mWidgetsListTableViewHolderBinder);
         mViewHolderBinders.put(
                 VIEW_TYPE_WIDGETS_HEADER,
@@ -122,6 +138,9 @@
                         /* onHeaderClickListener= */ this,
                         listDrawableFactory,
                         /* listAdapter= */ this));
+        mShortcutPreviewPadding =
+                2 * context.getResources()
+                        .getDimensionPixelSize(R.dimen.widget_preview_shortcut_padding);
     }
 
     @Override
@@ -177,6 +196,7 @@
 
     /** Updates the widget list based on {@code tempEntries}. */
     public void setWidgets(List<WidgetsListBaseEntry> tempEntries) {
+        mCachingPreviewLoader.clearAll();
         mAllEntries = tempEntries.stream().sorted(mRowComparator)
                 .collect(Collectors.toList());
         if (shouldClearVisibleEntries()) {
@@ -189,36 +209,110 @@
     public void setWidgetsOnSearch(List<WidgetsListBaseEntry> searchResults) {
         // Forget the expanded package every time widget list is refreshed in search mode.
         mWidgetsContentVisiblePackageUserKey = null;
+        cancelLoadingPreviews();
         setWidgets(searchResults);
     }
 
     private void updateVisibleEntries() {
-        mAllEntries.forEach(entry -> {
-            if (entry instanceof WidgetsListHeaderEntry) {
-                ((WidgetsListHeaderEntry) entry).setIsWidgetListShown(
-                        isHeaderForVisibleContent(entry));
-            } else if (entry instanceof WidgetsListSearchHeaderEntry) {
-                ((WidgetsListSearchHeaderEntry) entry).setIsWidgetListShown(
-                        isHeaderForVisibleContent(entry));
-            }
-        });
+        // If not all previews are ready, then defer this update and try again after the preview
+        // loads.
+        if (!ensureAllPreviewsReady()) return;
+
+        // Get the current top of the header with the matching key before adjusting the visible
+        // entries.
+        OptionalInt previousPositionForPackageUserKey =
+                getPositionForPackageUserKey(mPendingClickHeader);
+        OptionalInt topForPackageUserKey =
+                getOffsetForPosition(previousPositionForPackageUserKey);
+
         List<WidgetsListBaseEntry> newVisibleEntries = mAllEntries.stream()
                 .filter(entry -> (mFilter == null || mFilter.test(entry))
                         && mHeaderAndSelectedContentFilter.test(entry))
+                .map(entry -> {
+                    // Adjust the original entries to expand headers for the selected content.
+                    if (entry instanceof WidgetsListBaseEntry.Header<?>
+                            && matchesKey(entry, mWidgetsContentVisiblePackageUserKey)) {
+                        return ((WidgetsListBaseEntry.Header<?>) entry).withWidgetListShown();
+                    }
+                    return entry;
+                })
                 .collect(Collectors.toList());
+
         mDiffReporter.process(mVisibleEntries, newVisibleEntries, mRowComparator);
+
+        if (mPendingClickHeader != null) {
+            // Get the position for the clicked header after adjusting the visible entries. The
+            // position may have changed if another header had previously been expanded.
+            OptionalInt positionForPackageUserKey =
+                    getPositionForPackageUserKey(mPendingClickHeader);
+            scrollToPositionAndMaintainOffset(positionForPackageUserKey, topForPackageUserKey);
+            mPendingClickHeader = null;
+        }
     }
 
-    /** Returns whether {@code entry} matches {@link #mWidgetsContentVisiblePackageUserKey}. */
-    private boolean isHeaderForVisibleContent(WidgetsListBaseEntry entry) {
-        return isHeaderForPackageUserKey(entry, mWidgetsContentVisiblePackageUserKey);
+    /**
+     * Checks that all preview images are loaded and starts loading for those that aren't ready.
+     *
+     * @return true if all previews are ready and the data can be updated, false otherwise.
+     */
+    private boolean ensureAllPreviewsReady() {
+        boolean allReady = true;
+        BaseActivity activity = BaseActivity.fromContext(mContext);
+        for (WidgetsListBaseEntry entry : mAllEntries) {
+            if (!(entry instanceof WidgetsListContentEntry)) continue;
+
+            WidgetsListContentEntry contentEntry = (WidgetsListContentEntry) entry;
+            if (!matchesKey(entry, mWidgetsContentVisiblePackageUserKey)) {
+                // If the entry isn't visible, clear any loaded previews.
+                mCachingPreviewLoader.clearPreviews(contentEntry.mWidgets);
+                continue;
+            }
+
+            for (int i = 0; i < entry.mWidgets.size(); i++) {
+                WidgetItem widgetItem = entry.mWidgets.get(i);
+                DeviceProfile deviceProfile = activity.getDeviceProfile();
+                Size widgetSize =
+                        WidgetSizes.getWidgetSizePx(
+                                deviceProfile,
+                                widgetItem.spanX,
+                                widgetItem.spanY);
+                if (widgetItem.isShortcut()) {
+                    widgetSize =
+                            new Size(
+                                    widgetSize.getWidth() + mShortcutPreviewPadding,
+                                    widgetSize.getHeight() + mShortcutPreviewPadding);
+                }
+
+                if (widgetItem.hasPreviewLayout()
+                        || mCachingPreviewLoader.isPreviewLoaded(widgetItem, widgetSize)) {
+                    // The widget is ready if it can be rendered with a preview layout or if its
+                    // preview bitmap is in the cache.
+                    continue;
+                }
+
+                // If we've reached this point, we should load the preview for the widget.
+                allReady = false;
+                mCachingPreviewLoader.loadPreview(
+                        activity,
+                        widgetItem,
+                        widgetSize,
+                        mPreviewLoadedCallback);
+            }
+        }
+        return allReady;
     }
 
     /** Returns whether {@code entry} matches {@code key}. */
-    private boolean isHeaderForPackageUserKey(WidgetsListBaseEntry entry, PackageUserKey key) {
-        return (entry instanceof WidgetsListHeaderEntry
-                || entry instanceof WidgetsListSearchHeaderEntry)
-                && new PackageUserKey(entry.mPkgItem.packageName, entry.mPkgItem.user).equals(key);
+    private static boolean isHeaderForPackageUserKey(
+            @NonNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key) {
+        return entry instanceof WidgetsListBaseEntry.Header && matchesKey(entry, key);
+    }
+
+    private static boolean matchesKey(
+            @NonNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key) {
+        if (key == null) return false;
+        return entry.mPkgItem.packageName.equals(key.mPackageName)
+                && entry.mPkgItem.user.equals(key.mUser);
     }
 
     /**
@@ -227,6 +321,7 @@
     public void resetExpandedHeader() {
         if (mWidgetsContentVisiblePackageUserKey != null) {
             mWidgetsContentVisiblePackageUserKey = null;
+            cancelLoadingPreviews();
             updateVisibleEntries();
         }
     }
@@ -285,6 +380,8 @@
         // Ignore invalid clicks, such as collapsing a package that isn't currently expanded.
         if (!showWidgets && !packageUserKey.equals(mWidgetsContentVisiblePackageUserKey)) return;
 
+        cancelLoadingPreviews();
+
         if (showWidgets) {
             mWidgetsContentVisiblePackageUserKey = packageUserKey;
             mLauncher.getStatsLogManager().logger().log(LAUNCHER_WIDGETSTRAY_APP_EXPANDED);
@@ -292,17 +389,15 @@
             mWidgetsContentVisiblePackageUserKey = null;
         }
 
-        // Get the current top of the header with the matching key before adjusting the visible
-        // entries.
-        OptionalInt topForPackageUserKey =
-                getOffsetForPosition(getPositionForPackageUserKey(packageUserKey));
+        // Store the header that was clicked so that its position will be maintained the next time
+        // we update the entries.
+        mPendingClickHeader = packageUserKey;
 
         updateVisibleEntries();
+    }
 
-        // Get the position for the clicked header after adjusting the visible entries. The
-        // position may have changed if another header had previously been expanded.
-        OptionalInt positionForPackageUserKey = getPositionForPackageUserKey(packageUserKey);
-        scrollToPositionAndMaintainOffset(positionForPackageUserKey, topForPackageUserKey);
+    private void cancelLoadingPreviews() {
+        mCachingPreviewLoader.clearAll();
     }
 
     /** Returns the position of the currently expanded header, or empty if it's not present. */
@@ -315,7 +410,8 @@
      * Returns the position of {@code key} in {@link #mVisibleEntries}, or  empty if it's not
      * present.
      */
-    private OptionalInt getPositionForPackageUserKey(PackageUserKey key) {
+    @NonNull
+    private OptionalInt getPositionForPackageUserKey(@Nullable PackageUserKey key) {
         return IntStream.range(0, mVisibleEntries.size())
                 .filter(index -> isHeaderForPackageUserKey(mVisibleEntries.get(index), key))
                 .findFirst();
diff --git a/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinder.java b/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinder.java
index 7e8c55b..7b52663 100644
--- a/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinder.java
+++ b/src/com/android/launcher3/widget/picker/WidgetsListTableViewHolderBinder.java
@@ -18,8 +18,9 @@
 import static com.android.launcher3.widget.picker.WidgetsListDrawableState.LAST;
 import static com.android.launcher3.widget.picker.WidgetsListDrawableState.MIDDLE;
 
-import android.content.Context;
+import android.graphics.Bitmap;
 import android.util.Log;
+import android.util.Size;
 import android.view.Gravity;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -30,9 +31,9 @@
 import android.widget.TableRow;
 
 import com.android.launcher3.R;
-import com.android.launcher3.WidgetPreviewLoader;
 import com.android.launcher3.model.WidgetItem;
 import com.android.launcher3.recyclerview.ViewHolderBinder;
+import com.android.launcher3.widget.CachingWidgetPreviewLoader;
 import com.android.launcher3.widget.WidgetCell;
 import com.android.launcher3.widget.model.WidgetsListContentEntry;
 import com.android.launcher3.widget.util.WidgetsTableUtils;
@@ -52,17 +53,16 @@
     private final LayoutInflater mLayoutInflater;
     private final OnClickListener mIconClickListener;
     private final OnLongClickListener mIconLongClickListener;
-    private final WidgetPreviewLoader mWidgetPreviewLoader;
     private final WidgetsListDrawableFactory mListDrawableFactory;
+    private final CachingWidgetPreviewLoader mWidgetPreviewLoader;
     private final WidgetsListAdapter mWidgetsListAdapter;
     private boolean mApplyBitmapDeferred = false;
 
     public WidgetsListTableViewHolderBinder(
-            Context context,
             LayoutInflater layoutInflater,
             OnClickListener iconClickListener,
             OnLongClickListener iconLongClickListener,
-            WidgetPreviewLoader widgetPreviewLoader,
+            CachingWidgetPreviewLoader widgetPreviewLoader,
             WidgetsListDrawableFactory listDrawableFactory,
             WidgetsListAdapter listAdapter) {
         mLayoutInflater = layoutInflater;
@@ -75,7 +75,7 @@
 
     /**
      * Defers applying bitmap on all the {@link WidgetCell} at
-     * {@link #bindViewHolder(WidgetsRowViewHolder, WidgetsListContentEntry)} if
+     * {@link #bindViewHolder(WidgetsRowViewHolder, WidgetsListContentEntry, int)} if
      * {@code applyBitmapDeferred} is {@code true}.
      */
     public void setApplyBitmapDeferred(boolean applyBitmapDeferred) {
@@ -124,10 +124,15 @@
                 WidgetCell widget = (WidgetCell) row.getChildAt(j);
                 widget.clear();
                 WidgetItem widgetItem = widgetItemsPerRow.get(j);
-                widget.setPreviewSize(widgetItem.spanX, widgetItem.spanY);
+                Size previewSize = widget.setPreviewSize(widgetItem.spanX, widgetItem.spanY);
                 widget.applyFromCellItem(widgetItem, mWidgetPreviewLoader);
                 widget.setApplyBitmapDeferred(mApplyBitmapDeferred);
-                widget.ensurePreview();
+                Bitmap preview = mWidgetPreviewLoader.getPreview(widgetItem, previewSize);
+                if (preview == null) {
+                    widget.ensurePreview();
+                } else {
+                    widget.applyPreview(preview);
+                }
                 widget.setVisibility(View.VISIBLE);
             }
         }