diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepWidgetHolder.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepWidgetHolder.java index 0fb2b17eef..e3ff281e4d 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepWidgetHolder.java +++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepWidgetHolder.java @@ -219,6 +219,20 @@ public final class QuickstepWidgetHolder extends LauncherWidgetHolder { return () -> holderListener.mListeningHolders.remove(handler); } + /** + * Recycling logic: + * The holder doesn't maintain any states associated with the view, so if the view was + * initially initialized by this holder, all its state are already set in the view. We just + * update the RemoteViews for this view again, in case the widget sent an update during the + * time between inflation and recycle. + */ + @Override + protected LauncherAppWidgetHostView recycleExistingView(LauncherAppWidgetHostView view) { + RemoteViews views = getHolderListener(view.getAppWidgetId()).addHolder(mUpdateHandler); + view.updateAppWidget(views); + return view; + } + @NonNull @Override protected LauncherAppWidgetHostView createViewInternal( diff --git a/src/com/android/launcher3/Alarm.java b/src/com/android/launcher3/Alarm.java index e4aebf606d..fb8088c13b 100644 --- a/src/com/android/launcher3/Alarm.java +++ b/src/com/android/launcher3/Alarm.java @@ -17,6 +17,7 @@ package com.android.launcher3; import android.os.Handler; +import android.os.Looper; import android.os.SystemClock; public class Alarm implements Runnable{ @@ -33,7 +34,11 @@ public class Alarm implements Runnable{ private long mLastSetTimeout; public Alarm() { - mHandler = new Handler(); + this(Looper.myLooper()); + } + + public Alarm(Looper looper) { + mHandler = new Handler(looper); } public void setOnAlarmListener(OnAlarmListener alarmListener) { diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java index edfef5eaf2..1ab6222c98 100644 --- a/src/com/android/launcher3/Launcher.java +++ b/src/com/android/launcher3/Launcher.java @@ -28,6 +28,7 @@ import static com.android.launcher3.AbstractFloatingView.TYPE_ICON_SURFACE; import static com.android.launcher3.AbstractFloatingView.TYPE_REBIND_SAFE; import static com.android.launcher3.AbstractFloatingView.getTopOpenViewWithType; import static com.android.launcher3.Flags.enableAddAppWidgetViaConfigActivityV2; +import static com.android.launcher3.Flags.enableWorkspaceInflation; import static com.android.launcher3.LauncherAnimUtils.HOTSEAT_SCALE_PROPERTY_FACTORY; import static com.android.launcher3.LauncherAnimUtils.SCALE_INDEX_WIDGET_TRANSITION; import static com.android.launcher3.LauncherAnimUtils.SPRING_LOADED_EXIT_DELAY; @@ -1485,8 +1486,8 @@ public class Launcher extends StatefulActivity CellPos presenterPos = getCellPosMapper().mapModelToPresenter(itemInfo); if (showPendingWidget) { launcherInfo.restoreStatus = LauncherAppWidgetInfo.FLAG_UI_NOT_READY; - PendingAppWidgetHostView pendingAppWidgetHostView = - new PendingAppWidgetHostView(this, launcherInfo, appWidgetInfo); + PendingAppWidgetHostView pendingAppWidgetHostView = new PendingAppWidgetHostView( + this, mAppWidgetHolder, launcherInfo, appWidgetInfo); pendingAppWidgetHostView.setPreviewBitmap(widgetPreviewBitmap); hostView = pendingAppWidgetHostView; } else if (hostView instanceof PendingAppWidgetHostView) { @@ -2187,17 +2188,23 @@ public class Launcher extends StatefulActivity */ @Override public void bindItems(final List items, final boolean forceAnimateIcons) { - bindItems(items.stream().map(i -> Pair.create( + bindInflatedItems(items.stream().map(i -> Pair.create( i, getItemInflater().inflateItem(i, getModelWriter()))).toList(), forceAnimateIcons ? new AnimatorSet() : null); } + @Override + public void bindInflatedItems(List> items) { + bindInflatedItems(items, null); + } + /** * Bind all the items in the map, ignoring any null views * * @param boundAnim if non-null, uses it to create and play the bounce animation for added views */ - public void bindItems(List> shortcuts, @Nullable AnimatorSet boundAnim) { + public void bindInflatedItems( + List> shortcuts, @Nullable AnimatorSet boundAnim) { // Get the list of added items and intersect them with the set of items here Workspace workspace = mWorkspace; int newItemsScreenId = -1; @@ -2222,10 +2229,13 @@ public class Launcher extends StatefulActivity } } - final View view = e.second; + View view = e.second; if (view == null) { continue; } + if (enableWorkspaceInflation() && view instanceof LauncherAppWidgetHostView lv) { + view = getAppWidgetHolder().attachViewToHostAndGetAttachedView(lv); + } workspace.addInScreenFromBind(view, item); if (boundAnim != null) { // Animate all the applications up now @@ -2324,9 +2334,9 @@ public class Launcher extends StatefulActivity @Override public void onInitialBindComplete(IntSet boundPages, RunnableList pendingTasks, - int workspaceItemCount, boolean isBindSync) { - mModelCallbacks.onInitialBindComplete(boundPages, pendingTasks, workspaceItemCount, - isBindSync); + RunnableList onCompleteSignal, int workspaceItemCount, boolean isBindSync) { + mModelCallbacks.onInitialBindComplete(boundPages, pendingTasks, onCompleteSignal, + workspaceItemCount, isBindSync); } /** @@ -3057,6 +3067,7 @@ public class Launcher extends StatefulActivity return super.getStatsLogManager().withDefaultInstanceId(mAllAppsSessionLogId); } + @Override public ItemInflater getItemInflater() { return mItemInflater; } diff --git a/src/com/android/launcher3/ModelCallbacks.kt b/src/com/android/launcher3/ModelCallbacks.kt index 9867556136..9b65a310eb 100644 --- a/src/com/android/launcher3/ModelCallbacks.kt +++ b/src/com/android/launcher3/ModelCallbacks.kt @@ -72,6 +72,7 @@ class ModelCallbacks(private var launcher: Launcher) : BgDataModel.Callbacks { override fun onInitialBindComplete( boundPages: LIntSet, pendingTasks: RunnableList, + onCompleteSignal: RunnableList, workspaceItemCount: Int, isBindSync: Boolean ) { @@ -99,7 +100,14 @@ class ModelCallbacks(private var launcher: Launcher) : BgDataModel.Callbacks { } } pendingExecutor = executor - executor.attachTo(launcher) + + if (Flags.enableWorkspaceInflation()) { + // Finish the executor as soon as the pending inflation is completed + onCompleteSignal.add(executor::markCompleted) + } else { + // Pending executor is already completed, wait until first draw to run the tasks + executor.attachTo(launcher) + } launcher.bindComplete(workspaceItemCount, isBindSync) } @@ -409,4 +417,6 @@ class ModelCallbacks(private var launcher: Launcher) : BgDataModel.Callbacks { } fun getIsFirstPagePinnedItemEnabled(): Boolean = isFirstPagePinnedItemEnabled + + override fun getItemInflater() = launcher.itemInflater } diff --git a/src/com/android/launcher3/accessibility/LauncherAccessibilityDelegate.java b/src/com/android/launcher3/accessibility/LauncherAccessibilityDelegate.java index a846e683ee..e861d38733 100644 --- a/src/com/android/launcher3/accessibility/LauncherAccessibilityDelegate.java +++ b/src/com/android/launcher3/accessibility/LauncherAccessibilityDelegate.java @@ -440,7 +440,7 @@ public class LauncherAccessibilityDelegate extends BaseAccessibilityDelegate view.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED))); } - mContext.bindItems(Collections.singletonList(Pair.create(item, view)), anim); + mContext.bindInflatedItems(Collections.singletonList(Pair.create(item, view)), anim); } /** diff --git a/src/com/android/launcher3/folder/Folder.java b/src/com/android/launcher3/folder/Folder.java index f013126409..ec9c27dfab 100644 --- a/src/com/android/launcher3/folder/Folder.java +++ b/src/com/android/launcher3/folder/Folder.java @@ -40,6 +40,7 @@ import android.graphics.Path; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.graphics.drawable.GradientDrawable; +import android.os.Looper; import android.text.InputType; import android.text.Selection; import android.text.TextUtils; @@ -165,10 +166,10 @@ public class Folder extends AbstractFloatingView implements ClipPathView, DragSo private static final Rect sTempRect = new Rect(); private static final int MIN_FOLDERS_FOR_HARDWARE_OPTIMIZATION = 10; - private final Alarm mReorderAlarm = new Alarm(); - private final Alarm mOnExitAlarm = new Alarm(); - private final Alarm mOnScrollHintAlarm = new Alarm(); - final Alarm mScrollPauseAlarm = new Alarm(); + private final Alarm mReorderAlarm = new Alarm(Looper.getMainLooper()); + private final Alarm mOnExitAlarm = new Alarm(Looper.getMainLooper()); + private final Alarm mOnScrollHintAlarm = new Alarm(Looper.getMainLooper()); + final Alarm mScrollPauseAlarm = new Alarm(Looper.getMainLooper()); final ArrayList mItemsInReadingOrder = new ArrayList(); diff --git a/src/com/android/launcher3/folder/FolderIcon.java b/src/com/android/launcher3/folder/FolderIcon.java index 284b31e971..ee0d5fce24 100644 --- a/src/com/android/launcher3/folder/FolderIcon.java +++ b/src/com/android/launcher3/folder/FolderIcon.java @@ -32,6 +32,7 @@ import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.os.Looper; import android.util.AttributeSet; import android.util.Property; import android.view.LayoutInflater; @@ -121,7 +122,7 @@ public class FolderIcon extends FrameLayout implements FolderListener, IconLabel boolean mAnimating = false; - private Alarm mOpenAlarm = new Alarm(); + private Alarm mOpenAlarm = new Alarm(Looper.getMainLooper()); private boolean mForceHideDot; @ViewDebug.ExportedProperty(category = "launcher", deepExport = true) diff --git a/src/com/android/launcher3/icons/IconCache.java b/src/com/android/launcher3/icons/IconCache.java index ee66a60c03..8e73660ae0 100644 --- a/src/com/android/launcher3/icons/IconCache.java +++ b/src/com/android/launcher3/icons/IconCache.java @@ -37,6 +37,7 @@ import android.content.pm.ShortcutInfo; import android.database.Cursor; import android.database.sqlite.SQLiteException; import android.graphics.drawable.Drawable; +import android.os.Looper; import android.os.Process; import android.os.Trace; import android.os.UserHandle; @@ -44,6 +45,7 @@ import android.text.TextUtils; import android.util.Log; import android.util.SparseArray; +import androidx.annotation.AnyThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -65,7 +67,6 @@ import com.android.launcher3.shortcuts.ShortcutKey; import com.android.launcher3.util.CancellableTask; import com.android.launcher3.util.InstantAppResolver; import com.android.launcher3.util.PackageUserKey; -import com.android.launcher3.util.Preconditions; import com.android.launcher3.widget.WidgetSections; import com.android.launcher3.widget.WidgetSections.WidgetSection; @@ -173,9 +174,9 @@ public class IconCache extends BaseIconCache { * * @return a request ID that can be used to cancel the request. */ + @AnyThread public CancellableTask updateIconInBackground(final ItemInfoUpdateReceiver caller, final ItemInfoWithIcon info) { - Preconditions.assertUIThread(); Supplier task; if (info instanceof AppInfo || info instanceof WorkspaceItemInfo) { task = () -> { @@ -193,13 +194,19 @@ public class IconCache extends BaseIconCache { return mCancelledTask; } - if (mPendingIconRequestCount <= 0) { - MODEL_EXECUTOR.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND); + Runnable endRunnable; + if (Looper.myLooper() == Looper.getMainLooper()) { + if (mPendingIconRequestCount <= 0) { + MODEL_EXECUTOR.setThreadPriority(Process.THREAD_PRIORITY_FOREGROUND); + } + mPendingIconRequestCount++; + endRunnable = this::onIconRequestEnd; + } else { + endRunnable = () -> { }; } - mPendingIconRequestCount++; CancellableTask request = new CancellableTask<>( - task, MAIN_EXECUTOR, caller::reapplyItemInfo, this::onIconRequestEnd); + task, MAIN_EXECUTOR, caller::reapplyItemInfo, endRunnable); Utilities.postAsyncCallback(mWorkerHandler, request); return request; } diff --git a/src/com/android/launcher3/model/BaseLauncherBinder.java b/src/com/android/launcher3/model/BaseLauncherBinder.java index 9b2344d2ad..fa2a1b01c7 100644 --- a/src/com/android/launcher3/model/BaseLauncherBinder.java +++ b/src/com/android/launcher3/model/BaseLauncherBinder.java @@ -16,20 +16,25 @@ package com.android.launcher3.model; +import static com.android.launcher3.Flags.enableWorkspaceInflation; import static com.android.launcher3.config.FeatureFlags.ENABLE_SMARTSPACE_REMOVAL; import static com.android.launcher3.model.ItemInstallQueue.FLAG_LOADER_RUNNING; import static com.android.launcher3.model.ModelUtils.filterCurrentWorkspaceItems; +import static com.android.launcher3.util.Executors.MAIN_EXECUTOR; import static com.android.launcher3.util.Executors.MODEL_EXECUTOR; import android.os.Process; import android.os.Trace; import android.util.Log; +import android.util.Pair; +import android.view.View; import com.android.launcher3.InvariantDeviceProfile; import com.android.launcher3.LauncherAppState; import com.android.launcher3.LauncherModel.CallbackTask; import com.android.launcher3.LauncherSettings; import com.android.launcher3.Workspace; +import com.android.launcher3.celllayout.CellPosMapper; import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.model.BgDataModel.Callbacks; import com.android.launcher3.model.BgDataModel.FixedContainerItems; @@ -38,6 +43,7 @@ import com.android.launcher3.model.data.ItemInfo; import com.android.launcher3.model.data.LauncherAppWidgetInfo; import com.android.launcher3.util.IntArray; import com.android.launcher3.util.IntSet; +import com.android.launcher3.util.ItemInflater; import com.android.launcher3.util.LooperExecutor; import com.android.launcher3.util.LooperIdleLock; import com.android.launcher3.util.PackageUserKey; @@ -279,8 +285,8 @@ public abstract class BaseLauncherBinder { // Separate the items that are on the current screen, and all the other remaining items ArrayList currentWorkspaceItems = new ArrayList<>(); ArrayList otherWorkspaceItems = new ArrayList<>(); - ArrayList currentAppWidgets = new ArrayList<>(); - ArrayList otherAppWidgets = new ArrayList<>(); + ArrayList currentAppWidgets = new ArrayList<>(); + ArrayList otherAppWidgets = new ArrayList<>(); filterCurrentWorkspaceItems(currentScreenIds, mWorkspaceItems, currentWorkspaceItems, otherWorkspaceItems); @@ -304,8 +310,8 @@ public abstract class BaseLauncherBinder { executeCallbacksTask(c -> c.bindScreens(mOrderedScreenIds), mUiExecutor); // Load items on the current page. - bindWorkspaceItems(currentWorkspaceItems, mUiExecutor); - bindAppWidgets(currentAppWidgets, mUiExecutor); + bindItemsInChunks(currentWorkspaceItems, ITEMS_CHUNK, mUiExecutor); + bindItemsInChunks(currentAppWidgets, 1, mUiExecutor); if (!FeatureFlags.CHANGE_MODEL_DELEGATE_LOADING_ORDER.get()) { mExtraItems.forEach(item -> executeCallbacksTask(c -> c.bindExtraContainerItems(item), mUiExecutor)); @@ -313,8 +319,41 @@ public abstract class BaseLauncherBinder { RunnableList pendingTasks = new RunnableList(); Executor pendingExecutor = pendingTasks::add; - bindWorkspaceItems(otherWorkspaceItems, pendingExecutor); - bindAppWidgets(otherAppWidgets, pendingExecutor); + + RunnableList onCompleteSignal = new RunnableList(); + + if (enableWorkspaceInflation()) { + MODEL_EXECUTOR.execute(() -> { + setupPendingBind(otherWorkspaceItems, otherAppWidgets, currentScreenIds, + pendingExecutor); + + // Wait for the async inflation to complete and then notify the completion + // signal on UI thread. + MAIN_EXECUTOR.execute(onCompleteSignal::executeAllAndDestroy); + }); + } else { + setupPendingBind( + otherWorkspaceItems, otherAppWidgets, currentScreenIds, pendingExecutor); + onCompleteSignal.executeAllAndDestroy(); + } + + executeCallbacksTask( + c -> { + if (!enableWorkspaceInflation()) { + MODEL_EXECUTOR.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + } + c.onInitialBindComplete(currentScreenIds, pendingTasks, onCompleteSignal, + workspaceItemCount, isBindSync); + }, mUiExecutor); + } + + private void setupPendingBind( + List otherWorkspaceItems, + List otherAppWidgets, + IntSet currentScreenIds, + Executor pendingExecutor) { + bindItemsInChunks(otherWorkspaceItems, ITEMS_CHUNK, pendingExecutor); + bindItemsInChunks(otherAppWidgets, 1, pendingExecutor); StringCache cacheClone = mBgDataModel.stringCache.clone(); executeCallbacksTask(c -> c.bindStringCache(cacheClone), pendingExecutor); @@ -326,38 +365,51 @@ public abstract class BaseLauncherBinder { ItemInstallQueue.INSTANCE.get(mApp.getContext()) .resumeModelPush(FLAG_LOADER_RUNNING); }); - - executeCallbacksTask( - c -> { - MODEL_EXECUTOR.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); - c.onInitialBindComplete( - currentScreenIds, pendingTasks, workspaceItemCount, isBindSync); - }, mUiExecutor); } - private void bindWorkspaceItems( - final ArrayList workspaceItems, final Executor executor) { + /** + * Tries to inflate the items asynchronously and bind. Returns true on success or false if + * async-binding is not supported in this case. + */ + private boolean inflateAsyncAndBind(List items, Executor executor) { + if (!enableWorkspaceInflation()) { + return false; + } + ItemInflater inflater = mCallbacks.getItemInflater(); + if (inflater == null) { + return false; + } + + if (mMyBindingId != mBgDataModel.lastBindId) { + Log.d(TAG, "Too many consecutive reloads, skipping obsolete view inflation"); + return true; + } + + ModelWriter writer = mApp.getModel() + .getWriter(false /* verifyChanges */, CellPosMapper.DEFAULT, null); + List> bindItems = items.stream().map(i -> + Pair.create(i, inflater.inflateItem(i, writer, null))).toList(); + executeCallbacksTask(c -> c.bindInflatedItems(bindItems), executor); + return true; + } + + private void bindItemsInChunks(List workspaceItems, int chunkCount, + Executor executor) { + if (inflateAsyncAndBind(workspaceItems, executor)) { + return; + } + // Bind the workspace items int count = workspaceItems.size(); - for (int i = 0; i < count; i += ITEMS_CHUNK) { + for (int i = 0; i < count; i += chunkCount) { final int start = i; - final int chunkSize = (i + ITEMS_CHUNK <= count) ? ITEMS_CHUNK : (count - i); + final int chunkSize = (i + chunkCount <= count) ? chunkCount : (count - i); executeCallbacksTask( c -> c.bindItems(workspaceItems.subList(start, start + chunkSize), false), executor); } } - private void bindAppWidgets(List appWidgets, Executor executor) { - // Bind the widgets, one at a time - int count = appWidgets.size(); - for (int i = 0; i < count; i++) { - final ItemInfo widget = appWidgets.get(i); - executeCallbacksTask( - c -> c.bindItems(Collections.singletonList(widget), false), executor); - } - } - protected void executeCallbacksTask(CallbackTask task, Executor executor) { executor.execute(() -> { if (mMyBindingId != mBgDataModel.lastBindId) { @@ -430,8 +482,11 @@ public abstract class BaseLauncherBinder { bindAppWidgets(appWidgets); executeCallbacksTask(c -> { MODEL_EXECUTOR.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); - c.onInitialBindComplete( - mCurrentScreenIds, new RunnableList(), workspaceItemCount, isBindSync); + + RunnableList onCompleteSignal = new RunnableList(); + onCompleteSignal.executeAllAndDestroy(); + c.onInitialBindComplete(mCurrentScreenIds, new RunnableList(), onCompleteSignal, + workspaceItemCount, isBindSync); }, mUiExecutor); } diff --git a/src/com/android/launcher3/model/BgDataModel.java b/src/com/android/launcher3/model/BgDataModel.java index 7f0f683091..8579d1d682 100644 --- a/src/com/android/launcher3/model/BgDataModel.java +++ b/src/com/android/launcher3/model/BgDataModel.java @@ -33,6 +33,8 @@ import android.os.UserHandle; import android.text.TextUtils; import android.util.ArraySet; import android.util.Log; +import android.util.Pair; +import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -54,6 +56,7 @@ import com.android.launcher3.util.ComponentKey; import com.android.launcher3.util.IntArray; import com.android.launcher3.util.IntSet; import com.android.launcher3.util.IntSparseArrayMap; +import com.android.launcher3.util.ItemInflater; import com.android.launcher3.util.PackageUserKey; import com.android.launcher3.util.RunnableList; import com.android.launcher3.widget.model.WidgetsListBaseEntry; @@ -495,7 +498,15 @@ public class BgDataModel { default void clearPendingBinds() { } default void startBinding() { } - default void bindItems(List shortcuts, boolean forceAnimateIcons) { } + @Nullable + default ItemInflater getItemInflater() { + return null; + } + + default void bindItems(@NonNull List shortcuts, boolean forceAnimateIcons) { } + /** Alternate method to bind preinflated views */ + default void bindInflatedItems(@NonNull List> items) { } + default void bindScreens(IntArray orderedScreenIds) { } default void setIsFirstPagePinnedItemEnabled(boolean isFirstPagePinnedItemEnabled) { } default void finishBindingItems(IntSet pagesBoundFirst) { } @@ -520,7 +531,9 @@ public class BgDataModel { default void bindSmartspaceWidget() { } /** Called when workspace has been bound. */ - default void onInitialBindComplete(IntSet boundPages, RunnableList pendingTasks, + default void onInitialBindComplete(@NonNull IntSet boundPages, + @NonNull RunnableList pendingTasks, + @NonNull RunnableList onCompleteSignal, int workspaceItemCount, boolean isBindSync) { pendingTasks.executeAllAndDestroy(); } diff --git a/src/com/android/launcher3/model/ModelUtils.java b/src/com/android/launcher3/model/ModelUtils.java index bc51c9bfad..9e72e2823e 100644 --- a/src/com/android/launcher3/model/ModelUtils.java +++ b/src/com/android/launcher3/model/ModelUtils.java @@ -20,7 +20,6 @@ import com.android.launcher3.model.data.ItemInfo; import com.android.launcher3.util.IntArray; import com.android.launcher3.util.IntSet; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -37,9 +36,9 @@ public class ModelUtils { */ public static void filterCurrentWorkspaceItems( final IntSet currentScreenIds, - ArrayList allWorkspaceItems, - ArrayList currentScreenItems, - ArrayList otherScreenItems) { + List allWorkspaceItems, + List currentScreenItems, + List otherScreenItems) { // Purge any null ItemInfos allWorkspaceItems.removeIf(Objects::isNull); // Order the set of items by their containers first, this allows use to walk through the diff --git a/src/com/android/launcher3/util/ItemInflater.kt b/src/com/android/launcher3/util/ItemInflater.kt index 79091caaab..cc66af1189 100644 --- a/src/com/android/launcher3/util/ItemInflater.kt +++ b/src/com/android/launcher3/util/ItemInflater.kt @@ -121,7 +121,7 @@ class ItemInflater( } val view = if (type == WidgetInflater.TYPE_PENDING || widgetInfo == null) - PendingAppWidgetHostView(context, item, widgetInfo) + PendingAppWidgetHostView(context, widgetHolder, item, widgetInfo) else widgetHolder.createView(item.appWidgetId, widgetInfo) prepareAppWidget(view, item) return view diff --git a/src/com/android/launcher3/util/RunnableList.java b/src/com/android/launcher3/util/RunnableList.java index f6e0c57fb2..2b8bf56a3b 100644 --- a/src/com/android/launcher3/util/RunnableList.java +++ b/src/com/android/launcher3/util/RunnableList.java @@ -69,4 +69,11 @@ public class RunnableList { } } } + + /** + * Returns true if the list has been destroyed + */ + public boolean isDestroyed() { + return mDestroyed; + } } diff --git a/src/com/android/launcher3/widget/LauncherAppWidgetHost.java b/src/com/android/launcher3/widget/LauncherAppWidgetHost.java index b1c477cbda..40c39840d6 100644 --- a/src/com/android/launcher3/widget/LauncherAppWidgetHost.java +++ b/src/com/android/launcher3/widget/LauncherAppWidgetHost.java @@ -54,6 +54,9 @@ class LauncherAppWidgetHost extends AppWidgetHost { @Nullable private final IntConsumer mAppWidgetRemovedCallback; + @Nullable + private ListenableHostView mViewToRecycle; + public LauncherAppWidgetHost(@NonNull Context context, @Nullable IntConsumer appWidgetRemovedCallback, List providerChangeListeners) { @@ -73,11 +76,21 @@ class LauncherAppWidgetHost extends AppWidgetHost { } } + /** + * Sets the view to be recycled for the next widget creation. + */ + public void recycleViewForNextCreation(ListenableHostView viewToRecycle) { + mViewToRecycle = viewToRecycle; + } + @Override @NonNull public LauncherAppWidgetHostView onCreateView(Context context, int appWidgetId, AppWidgetProviderInfo appWidget) { - return new ListenableHostView(context); + ListenableHostView result = + mViewToRecycle != null ? mViewToRecycle : new ListenableHostView(context); + mViewToRecycle = null; + return result; } /** diff --git a/src/com/android/launcher3/widget/LauncherAppWidgetHostView.java b/src/com/android/launcher3/widget/LauncherAppWidgetHostView.java index e77ec12e18..2259e3c0cd 100644 --- a/src/com/android/launcher3/widget/LauncherAppWidgetHostView.java +++ b/src/com/android/launcher3/widget/LauncherAppWidgetHostView.java @@ -40,12 +40,12 @@ import androidx.annotation.Nullable; import com.android.launcher3.CheckLongPressHelper; import com.android.launcher3.Flags; -import com.android.launcher3.Launcher; import com.android.launcher3.R; -import com.android.launcher3.dragndrop.DragLayer; import com.android.launcher3.model.data.ItemInfo; import com.android.launcher3.model.data.LauncherAppWidgetInfo; import com.android.launcher3.util.Themes; +import com.android.launcher3.views.ActivityContext; +import com.android.launcher3.views.BaseDragLayer; import com.android.launcher3.views.BaseDragLayer.TouchCompleteListener; /** @@ -72,7 +72,7 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView private final Rect mTempRect = new Rect(); private final CheckLongPressHelper mLongPressHelper; - protected final Launcher mLauncher; + protected final ActivityContext mActivityContext; // Maintain the color manager. private final LocalColorExtractor mColorExtractor; @@ -94,15 +94,15 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView public LauncherAppWidgetHostView(Context context) { super(context); - mLauncher = Launcher.getLauncher(context); + mActivityContext = ActivityContext.lookupContext(context); mLongPressHelper = new CheckLongPressHelper(this, this); - setAccessibilityDelegate(mLauncher.getAccessibilityDelegate()); + setAccessibilityDelegate(mActivityContext.getAccessibilityDelegate()); setBackgroundResource(R.drawable.widget_internal_focus_bg); if (Flags.enableFocusOutline()) { setDefaultFocusHighlightEnabled(false); } - if (Themes.getAttrBoolean(mLauncher, R.attr.isWorkspaceDarkText)) { + if (Themes.getAttrBoolean(context, R.attr.isWorkspaceDarkText)) { setOnLightBackground(true); } mColorExtractor = new LocalColorExtractor(); // no-op @@ -120,8 +120,7 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView @Override public boolean onLongClick(View view) { if (mIsScrollable) { - DragLayer dragLayer = mLauncher.getDragLayer(); - dragLayer.requestDisallowInterceptTouchEvent(false); + mActivityContext.getDragLayer().requestDisallowInterceptTouchEvent(false); } view.performLongClick(); return true; @@ -218,7 +217,7 @@ public class LauncherAppWidgetHostView extends BaseLauncherAppWidgetHostView public boolean onInterceptTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_DOWN) { - DragLayer dragLayer = mLauncher.getDragLayer(); + BaseDragLayer dragLayer = mActivityContext.getDragLayer(); if (mIsScrollable) { dragLayer.requestDisallowInterceptTouchEvent(true); } diff --git a/src/com/android/launcher3/widget/LauncherWidgetHolder.java b/src/com/android/launcher3/widget/LauncherWidgetHolder.java index 23127b3e4d..15bd6ed1f2 100644 --- a/src/com/android/launcher3/widget/LauncherWidgetHolder.java +++ b/src/com/android/launcher3/widget/LauncherWidgetHolder.java @@ -17,7 +17,9 @@ package com.android.launcher3.widget; import static android.app.Activity.RESULT_CANCELED; +import static com.android.launcher3.Flags.enableWorkspaceInflation; import static com.android.launcher3.util.Executors.MAIN_EXECUTOR; +import static com.android.launcher3.widget.LauncherAppWidgetProviderInfo.fromProviderInfo; import android.appwidget.AppWidgetHost; import android.appwidget.AppWidgetHostView; @@ -27,6 +29,7 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.os.Bundle; +import android.os.Looper; import android.util.SparseArray; import android.widget.Toast; @@ -310,7 +313,9 @@ public class LauncherWidgetHolder { } /** - * Create a view for the specified app widget + * Create a view for the specified app widget. When calling this method from a background + * thread, the returned view will not receive ongoing updates. The caller needs to reattach + * the view using {@link #attachViewToHostAndGetAttachedView} on UIThread * * @param appWidgetId The ID of the widget * @param appWidget The {@link LauncherAppWidgetProviderInfo} of the widget @@ -327,7 +332,55 @@ public class LauncherWidgetHolder { } LauncherAppWidgetHostView view = createViewInternal(appWidgetId, appWidget); - mViews.put(appWidgetId, view); + // Do not update mViews on a background thread call, as the holder is not thread safe. + if (!enableWorkspaceInflation() || Looper.myLooper() == Looper.getMainLooper()) { + mViews.put(appWidgetId, view); + } + return view; + } + + /** + * Attaches an already inflated view to the host. If the view can't be attached, creates + * and attaches a new view. + * @return the final attached view + */ + @NonNull + public final AppWidgetHostView attachViewToHostAndGetAttachedView( + @NonNull LauncherAppWidgetHostView view) { + if (mViews.get(view.getAppWidgetId()) != view) { + view = recycleExistingView(view); + mViews.put(view.getAppWidgetId(), view); + } + return view; + } + + /** + * Recycling logic: + * 1) If the final view should be a pendingView + * if the provided view is also a pendingView, return itself + * otherwise discard provided view and return a new pending view + * 2) If the recycled view is a pendingView, discard it and return a new view + * 3) Use the same for as creating a new view, but used the provided view in the host instead + * of creating a new view. This ensures that all the host callbacks are properly attached + * as a result of using the same flow. + */ + protected LauncherAppWidgetHostView recycleExistingView(LauncherAppWidgetHostView view) { + if ((mFlags & FLAG_LISTENING) == 0) { + if (view instanceof PendingAppWidgetHostView pv && pv.isDeferredWidget()) { + return view; + } else { + return new PendingAppWidgetHostView(mContext, this, view.getAppWidgetId(), + fromProviderInfo(mContext, view.getAppWidgetInfo())); + } + } + LauncherAppWidgetHost host = (LauncherAppWidgetHost) mWidgetHost; + if (view instanceof ListenableHostView lhv) { + host.recycleViewForNextCreation(lhv); + } + + view = createViewInternal( + view.getAppWidgetId(), fromProviderInfo(mContext, view.getAppWidgetInfo())); + host.recycleViewForNextCreation(null); return view; } @@ -338,8 +391,15 @@ public class LauncherWidgetHolder { // Since the launcher hasn't started listening to widget updates, we can't simply call // host.createView here because the later will make a binder call to retrieve // RemoteViews from system process. - return new PendingAppWidgetHostView(mContext, appWidgetId, appWidget); + return new PendingAppWidgetHostView(mContext, this, appWidgetId, appWidget); } else { + if (enableWorkspaceInflation() && Looper.myLooper() != Looper.getMainLooper()) { + // Widget is being inflated a background thread, just create and + // return a placeholder view + ListenableHostView hostView = new ListenableHostView(mContext); + hostView.setAppWidget(appWidgetId, appWidget); + return hostView; + } try { return (LauncherAppWidgetHostView) mWidgetHost.createView( mContext, appWidgetId, appWidget); diff --git a/src/com/android/launcher3/widget/PendingAppWidgetHostView.java b/src/com/android/launcher3/widget/PendingAppWidgetHostView.java index adf85c70e0..86400baaf2 100644 --- a/src/com/android/launcher3/widget/PendingAppWidgetHostView.java +++ b/src/com/android/launcher3/widget/PendingAppWidgetHostView.java @@ -50,6 +50,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.launcher3.DeviceProfile; +import com.android.launcher3.Launcher; import com.android.launcher3.LauncherAppState; import com.android.launcher3.R; import com.android.launcher3.icons.FastBitmapDrawable; @@ -65,7 +66,7 @@ import java.util.List; public class PendingAppWidgetHostView extends LauncherAppWidgetHostView implements OnClickListener, ItemInfoUpdateReceiver { private static final float SETUP_ICON_SIZE_FACTOR = 2f / 5; - private static final float MIN_SATUNATION = 0.7f; + private static final float MIN_SATURATION = 0.7f; private static final int FLAG_DRAW_SETTINGS = 1; private static final int FLAG_DRAW_ICON = 2; @@ -75,6 +76,7 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView private final Rect mRect = new Rect(); + private final LauncherWidgetHolder mWidgetHolder; private final LauncherAppWidgetProviderInfo mAppwidget; private final LauncherAppWidgetInfo mInfo; private final int mStartState; @@ -90,6 +92,7 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView private Drawable mSettingIconDrawable; private boolean mDrawableSizeChanged; + private boolean mIsDeferredWidget; private final TextPaint mPaint; @@ -98,13 +101,13 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView @Nullable private Bitmap mPreviewBitmap; - public PendingAppWidgetHostView(Context context, LauncherAppWidgetInfo info, - @Nullable LauncherAppWidgetProviderInfo appWidget) { - this(context, info, appWidget, + public PendingAppWidgetHostView(Context context, LauncherWidgetHolder widgetHolder, + LauncherAppWidgetInfo info, @Nullable LauncherAppWidgetProviderInfo appWidget) { + this(context, widgetHolder, info, appWidget, context.getResources().getText(R.string.gadget_complete_setup_text)); super.updateAppWidget(null); - setOnClickListener(mLauncher.getItemOnClickListener()); + setOnClickListener(mActivityContext.getItemOnClickListener()); if (info.pendingItemInfo == null) { info.pendingItemInfo = new PackageItemInfo(info.providerName.getPackageName(), @@ -117,14 +120,16 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView } public PendingAppWidgetHostView( - Context context, int appWidgetId, @NonNull LauncherAppWidgetProviderInfo appWidget) { - this(context, new LauncherAppWidgetInfo(appWidgetId, appWidget.provider), + Context context, LauncherWidgetHolder widgetHolder, + int appWidgetId, @NonNull LauncherAppWidgetProviderInfo appWidget) { + this(context, widgetHolder, new LauncherAppWidgetInfo(appWidgetId, appWidget.provider), appWidget, appWidget.label); getBackground().mutate().setAlpha(DEFERRED_ALPHA); mCenterDrawable = new ColorDrawable(Color.TRANSPARENT); mDragFlags = FLAG_DRAW_LABEL; mDrawableSizeChanged = true; + mIsDeferredWidget = true; } /** Set {@link Bitmap} of widget preview. */ @@ -136,10 +141,11 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView invalidate(); } - private PendingAppWidgetHostView(Context context, LauncherAppWidgetInfo info, + private PendingAppWidgetHostView(Context context, + LauncherWidgetHolder widgetHolder, LauncherAppWidgetInfo info, LauncherAppWidgetProviderInfo appwidget, CharSequence label) { super(new ContextThemeWrapper(context, R.style.WidgetContainerTheme)); - + mWidgetHolder = widgetHolder; mAppwidget = appwidget; mInfo = info; mStartState = info.restoreStatus; @@ -148,9 +154,12 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView mPaint = new TextPaint(); mPaint.setColor(Themes.getAttrColor(getContext(), android.R.attr.textColorPrimary)); - mPaint.setTextSize(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, - mLauncher.getDeviceProfile().iconTextSizePx, getResources().getDisplayMetrics())); + mPaint.setTextSize(TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_PX, + mActivityContext.getDeviceProfile().iconTextSizePx, + getResources().getDisplayMetrics())); mPreviewPaint = new Paint(ANTI_ALIAS_FLAG | DITHER_FLAG | FILTER_BITMAP_FLAG); + setWillNotDraw(false); setBackgroundResource(R.drawable.pending_widget_bg); } @@ -160,6 +169,11 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView return mAppwidget; } + @Override + public int getAppWidgetId() { + return mInfo.appWidgetId; + } + @Override public void updateAppWidget(RemoteViews remoteViews) { checkIfRestored(); @@ -172,6 +186,10 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView } } + public boolean isDeferredWidget() { + return mIsDeferredWidget; + } + @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); @@ -184,8 +202,8 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView if (mOnDetachCleanup != null) { mOnDetachCleanup.close(); } - mOnDetachCleanup = mLauncher.getAppWidgetHolder() - .addOnUpdateListener(mInfo.appWidgetId, mAppwidget, this::checkIfRestored); + mOnDetachCleanup = mWidgetHolder.addOnUpdateListener( + mInfo.appWidgetId, mAppwidget, this::checkIfRestored); checkIfRestored(); } } @@ -211,11 +229,13 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView // This occurs when LauncherAppWidgetHostView is used to render a preview layout. return; } - // Remove and rebind the current widget (which was inflated in the wrong - // orientation), but don't delete it from the database - mLauncher.removeItem(this, info, false /* deleteFromDb */, - "widget removed because of configuration change"); - mLauncher.bindAppWidget(info); + if (mActivityContext instanceof Launcher launcher) { + // Remove and rebind the current widget (which was inflated in the wrong + // orientation), but don't delete it from the database + launcher.removeItem(this, info, false /* deleteFromDb */, + "widget removed because of configuration change"); + launcher.bindAppWidget(info); + } } @Override @@ -303,7 +323,7 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView // Make the dominant color bright. float[] hsv = new float[3]; Color.colorToHSV(dominantColor, hsv); - hsv[1] = Math.min(hsv[1], MIN_SATUNATION); + hsv[1] = Math.min(hsv[1], MIN_SATURATION); hsv[2] = 1; mSettingIconDrawable.setColorFilter(Color.HSVToColor(hsv), PorterDuff.Mode.SRC_IN); } @@ -344,7 +364,7 @@ public class PendingAppWidgetHostView extends LauncherAppWidgetHostView } private void updateDrawableBounds() { - DeviceProfile grid = mLauncher.getDeviceProfile(); + DeviceProfile grid = mActivityContext.getDeviceProfile(); int paddingTop = getPaddingTop(); int paddingBottom = getPaddingBottom(); int paddingLeft = getPaddingLeft(); diff --git a/tests/src/com/android/launcher3/model/AsyncBindingTest.kt b/tests/src/com/android/launcher3/model/AsyncBindingTest.kt new file mode 100644 index 0000000000..af367a814a --- /dev/null +++ b/tests/src/com/android/launcher3/model/AsyncBindingTest.kt @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.model + +import android.os.Looper +import android.platform.test.flag.junit.SetFlagsRule +import android.util.Pair +import android.util.SparseArray +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.launcher3.Flags +import com.android.launcher3.model.BgDataModel.Callbacks +import com.android.launcher3.model.data.ItemInfo +import com.android.launcher3.util.Executors.MAIN_EXECUTOR +import com.android.launcher3.util.Executors.MODEL_EXECUTOR +import com.android.launcher3.util.IntArray +import com.android.launcher3.util.IntSet +import com.android.launcher3.util.ItemInflater +import com.android.launcher3.util.LauncherLayoutBuilder +import com.android.launcher3.util.LauncherModelHelper +import com.android.launcher3.util.LauncherModelHelper.TEST_PACKAGE +import com.android.launcher3.util.RunnableList +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.Spy +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.isNull +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** Tests to verify async binding of model views */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class AsyncBindingTest { + + @get:Rule val setFlagsRule = SetFlagsRule() + + @Spy private var callbacks = MyCallbacks() + @Mock private lateinit var itemInflater: ItemInflater<*> + + private val inflationLooper = SparseArray() + + private lateinit var modelHelper: LauncherModelHelper + + @Before + fun setUp() { + setFlagsRule.enableFlags(Flags.FLAG_ENABLE_WORKSPACE_INFLATION) + MockitoAnnotations.initMocks(this) + modelHelper = LauncherModelHelper() + + doAnswer { i -> + inflationLooper[(i.arguments[0] as ItemInfo).id] = Looper.myLooper() + View(modelHelper.sandboxContext) + } + .whenever(itemInflater) + .inflateItem(any(), any(), isNull()) + + // Set up the workspace with 3 pages of apps + modelHelper.setupDefaultLayoutProvider( + LauncherLayoutBuilder() + .atWorkspace(0, 1, 0) + .putApp(TEST_PACKAGE, TEST_PACKAGE) + .atWorkspace(1, 1, 0) + .putApp(TEST_PACKAGE, TEST_PACKAGE) + .atWorkspace(0, 1, 1) + .putApp(TEST_PACKAGE, TEST_PACKAGE) + .atWorkspace(1, 1, 1) + .putApp(TEST_PACKAGE, TEST_PACKAGE) + .atWorkspace(0, 1, 2) + .putApp(TEST_PACKAGE, TEST_PACKAGE) + ) + } + + @After + fun tearDown() { + modelHelper.destroy() + } + + @Test + fun test_bind_normally_without_itemInflater() { + MAIN_EXECUTOR.execute { modelHelper.model.addCallbacksAndLoad(callbacks) } + waitForLoaderAndTempMainThread() + + verify(callbacks, never()).bindInflatedItems(any()) + verify(callbacks, atLeastOnce()).bindItems(any(), any()) + } + + @Test + fun test_bind_inflates_item_on_background() { + callbacks.inflater = itemInflater + MAIN_EXECUTOR.execute { modelHelper.model.addCallbacksAndLoad(callbacks) } + waitForLoaderAndTempMainThread() + + verify(callbacks, never()).bindItems(any(), any()) + verify(callbacks, times(1)).bindInflatedItems(argThat { t -> t.size == 2 }) + + // Verify remaining items are bound using pendingTasks + reset(callbacks) + MAIN_EXECUTOR.submit(callbacks.pendingTasks!!::executeAllAndDestroy).get() + verify(callbacks, times(1)).bindInflatedItems(argThat { t -> t.size == 3 }) + + // Verify that all items were inflated on the background thread + assertEquals(5, inflationLooper.size()) + for (i in 0..4) assertEquals(MODEL_EXECUTOR.looper, inflationLooper.valueAt(i)) + } + + @Test + fun test_bind_sync_partially_inflates_on_background() { + modelHelper.loadModelSync() + assertTrue(modelHelper.model.isModelLoaded) + callbacks.inflater = itemInflater + + val firstPageBindIds = IntSet() + + MAIN_EXECUTOR.submit { + modelHelper.model.addCallbacksAndLoad(callbacks) + verify(callbacks, never()).bindItems(any(), any()) + verify(callbacks, times(1)) + .bindInflatedItems( + argThat { t -> + t.forEach { firstPageBindIds.add(it.first.id) } + t.size == 2 + } + ) + + // Verify that onInitialBindComplete is called and the binding is not yet complete + assertFalse(callbacks.onCompleteSignal!!.isDestroyed) + } + .get() + + waitForLoaderAndTempMainThread() + assertTrue(callbacks.onCompleteSignal!!.isDestroyed) + + // Verify that firstPageBindIds are loaded on the main thread and remaining + // on the background thread. + assertEquals(5, inflationLooper.size()) + for (i in 0..4) { + if (firstPageBindIds.contains(inflationLooper.keyAt(i))) + assertEquals(MAIN_EXECUTOR.looper, inflationLooper.valueAt(i)) + else assertEquals(MODEL_EXECUTOR.looper, inflationLooper.valueAt(i)) + } + + MAIN_EXECUTOR.submit { + reset(callbacks) + callbacks.pendingTasks!!.executeAllAndDestroy() + // Verify remaining 3 times are bound using pending tasks + verify(callbacks, times(1)).bindInflatedItems(argThat { t -> t.size == 3 }) + } + .get() + } + + private fun waitForLoaderAndTempMainThread() { + MAIN_EXECUTOR.submit {}.get() + MODEL_EXECUTOR.submit {}.get() + MAIN_EXECUTOR.submit {}.get() + } + + class MyCallbacks : Callbacks { + + var inflater: ItemInflater<*>? = null + var pendingTasks: RunnableList? = null + var onCompleteSignal: RunnableList? = null + + override fun bindItems(shortcuts: MutableList, forceAnimateIcons: Boolean) {} + + override fun bindInflatedItems(items: MutableList>) {} + + override fun getPagesToBindSynchronously(orderedScreenIds: IntArray?) = IntSet.wrap(0) + + override fun onInitialBindComplete( + boundPages: IntSet, + pendingTasks: RunnableList, + onCompleteSignal: RunnableList, + workspaceItemCount: Int, + isBindSync: Boolean + ) { + this.pendingTasks = pendingTasks + this.onCompleteSignal = onCompleteSignal + } + + override fun getItemInflater() = inflater + } +} diff --git a/tests/src/com/android/launcher3/model/ModelMultiCallbacksTest.java b/tests/src/com/android/launcher3/model/ModelMultiCallbacksTest.java index 25a4c4e8b8..b140f2efc7 100644 --- a/tests/src/com/android/launcher3/model/ModelMultiCallbacksTest.java +++ b/tests/src/com/android/launcher3/model/ModelMultiCallbacksTest.java @@ -187,7 +187,7 @@ public class ModelMultiCallbacksTest { @Override public void onInitialBindComplete(IntSet boundPages, RunnableList pendingTasks, - int workspaceItemCount, boolean isBindSync) { + RunnableList onCompleteSignal, int workspaceItemCount, boolean isBindSync) { mPageBoundSync = boundPages; mPendingTasks = pendingTasks; } diff --git a/tests/src/com/android/launcher3/util/ItemInflaterTest.kt b/tests/src/com/android/launcher3/util/ItemInflaterTest.kt new file mode 100644 index 0000000000..efad899e35 --- /dev/null +++ b/tests/src/com/android/launcher3/util/ItemInflaterTest.kt @@ -0,0 +1,313 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.launcher3.util + +import android.content.ComponentName +import android.content.Context +import android.content.pm.LauncherApps +import android.os.Bundle +import android.os.Process +import android.platform.test.flag.junit.SetFlagsRule +import android.view.View.OnClickListener +import android.view.View.OnFocusChangeListener +import android.widget.FrameLayout +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.launcher3.BubbleTextView +import com.android.launcher3.Flags +import com.android.launcher3.LauncherSettings.Favorites.ITEM_TYPE_APP_PAIR +import com.android.launcher3.apppairs.AppPairIcon +import com.android.launcher3.folder.FolderIcon +import com.android.launcher3.model.ModelWriter +import com.android.launcher3.model.data.AppInfo +import com.android.launcher3.model.data.FolderInfo +import com.android.launcher3.model.data.LauncherAppWidgetInfo +import com.android.launcher3.model.data.LauncherAppWidgetInfo.FLAG_ID_NOT_VALID +import com.android.launcher3.model.data.LauncherAppWidgetInfo.FLAG_UI_NOT_READY +import com.android.launcher3.model.data.LauncherAppWidgetInfo.RESTORE_COMPLETED +import com.android.launcher3.ui.TestViewHelpers +import com.android.launcher3.util.Executors.MAIN_EXECUTOR +import com.android.launcher3.util.Executors.VIEW_PREINFLATION_EXECUTOR +import com.android.launcher3.util.rule.ShellCommandRule +import com.android.launcher3.widget.LauncherAppWidgetHostView +import com.android.launcher3.widget.LauncherWidgetHolder +import com.android.launcher3.widget.PendingAppWidgetHostView +import com.android.launcher3.widget.WidgetManagerHelper +import java.util.concurrent.Callable +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.same +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions + +/** Tests for ItemInflater */ +@SmallTest +@RunWith(AndroidJUnit4::class) +class ItemInflaterTest { + + @get:Rule val setFlagsRule = SetFlagsRule() + @get:Rule val grantWidgetRule = ShellCommandRule.grantWidgetBind() + + private val clickListener = OnClickListener {} + private val focusListener = OnFocusChangeListener { _, _ -> } + + @Mock private lateinit var modelWriter: ModelWriter + + private lateinit var testContext: Context + private lateinit var uiContext: ActivityContextWrapper + + private lateinit var widgetHolder: LauncherWidgetHolder + private lateinit var underTest: ItemInflater<*> + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + testContext = InstrumentationRegistry.getInstrumentation().context + + uiContext = ActivityContextWrapper(getApplicationContext()) + uiContext.setTheme(Themes.getActivityThemeRes(uiContext, 0)) + + widgetHolder = LauncherWidgetHolder.newInstance(uiContext) + widgetHolder.startListening() + underTest = + ItemInflater( + uiContext, + widgetHolder, + clickListener, + focusListener, + FrameLayout(uiContext) + ) + } + + @After + fun tearDown() { + widgetHolder.destroy() + } + + @Test + fun test_workspace_item_inflated_on_UI() { + val itemInfo = workspaceItemInfo() + val view = + MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get() + + assertTrue(view is BubbleTextView) + assertEquals(itemInfo, view!!.tag) + } + + @Test + fun test_workspace_item_inflated_on_BG() { + setFlagsRule.enableFlags(Flags.FLAG_ENABLE_WORKSPACE_INFLATION) + + val itemInfo = workspaceItemInfo() + val view = + VIEW_PREINFLATION_EXECUTOR.submit( + Callable { underTest.inflateItem(itemInfo, modelWriter) } + ) + .get() + + assertTrue(view is BubbleTextView) + assertEquals(itemInfo, view!!.tag) + } + + @Test + fun test_folder_inflated_on_UI() { + val itemInfo = FolderInfo() + itemInfo.contents.add(workspaceItemInfo()) + itemInfo.contents.add(workspaceItemInfo()) + itemInfo.contents.add(workspaceItemInfo()) + + val view = + MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get() + + assertTrue(view is FolderIcon) + assertEquals(itemInfo, view!!.tag) + } + + @Test + fun test_folder_inflated_on_BG() { + setFlagsRule.enableFlags(Flags.FLAG_ENABLE_WORKSPACE_INFLATION) + + val itemInfo = FolderInfo() + itemInfo.contents.add(workspaceItemInfo()) + itemInfo.contents.add(workspaceItemInfo()) + itemInfo.contents.add(workspaceItemInfo()) + + val view = + VIEW_PREINFLATION_EXECUTOR.submit( + Callable { underTest.inflateItem(itemInfo, modelWriter) } + ) + .get() + + assertTrue(view is FolderIcon) + assertEquals(itemInfo, view!!.tag) + } + + @Test + fun test_app_pair_inflated_on_UI() { + val itemInfo = FolderInfo() + itemInfo.itemType = ITEM_TYPE_APP_PAIR + itemInfo.contents.add(workspaceItemInfo()) + itemInfo.contents.add(workspaceItemInfo()) + + val view = + MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get() + + assertTrue(view is AppPairIcon) + assertEquals(itemInfo, view!!.tag) + } + + @Test + fun test_app_pair_inflated_on_BG() { + setFlagsRule.enableFlags(Flags.FLAG_ENABLE_WORKSPACE_INFLATION) + + val itemInfo = FolderInfo() + itemInfo.itemType = ITEM_TYPE_APP_PAIR + itemInfo.contents.add(workspaceItemInfo()) + itemInfo.contents.add(workspaceItemInfo()) + + val view = + VIEW_PREINFLATION_EXECUTOR.submit( + Callable { underTest.inflateItem(itemInfo, modelWriter) } + ) + .get() + + assertTrue(view is AppPairIcon) + assertEquals(itemInfo, view!!.tag) + } + + @Test + fun test_pending_widget_inflated_on_UI() { + val itemInfo = widgetItemInfo(true) + + val view = + MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get() + + assertTrue(view is PendingAppWidgetHostView) + assertEquals(itemInfo, view!!.tag) + } + + @Test + fun test_pending_widget_inflated_on_BG() { + setFlagsRule.enableFlags(Flags.FLAG_ENABLE_WORKSPACE_INFLATION) + + val itemInfo = widgetItemInfo(true) + val view = + VIEW_PREINFLATION_EXECUTOR.submit( + Callable { underTest.inflateItem(itemInfo, modelWriter) } + ) + .get() + + assertTrue(view is PendingAppWidgetHostView) + assertEquals(itemInfo, view!!.tag) + } + + @Test + fun test_widget_restored_and_inflated_on_UI() { + val itemInfo = widgetItemInfo(false) + + val view = + MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get() + + // Verify that the widget is automatically restored and a final widget is returned + assertTrue(view is LauncherAppWidgetHostView) + assertFalse(view is PendingAppWidgetHostView) + assertEquals(itemInfo, view!!.tag) + assertEquals(RESTORE_COMPLETED, itemInfo.restoreStatus) + verify(modelWriter).updateItemInDatabase(same(itemInfo)) + } + + @Test + fun test_widget_restored_and_inflated_on_BG() { + setFlagsRule.enableFlags(Flags.FLAG_ENABLE_WORKSPACE_INFLATION) + val itemInfo = widgetItemInfo(false) + + val view = + VIEW_PREINFLATION_EXECUTOR.submit( + Callable { underTest.inflateItem(itemInfo, modelWriter) } + ) + .get() + + // Verify that the widget is automatically restored and a final widget is returned + assertTrue(view is LauncherAppWidgetHostView) + assertFalse(view is PendingAppWidgetHostView) + assertEquals(itemInfo, view!!.tag) + assertEquals(RESTORE_COMPLETED, itemInfo.restoreStatus) + verify(modelWriter).updateItemInDatabase(same(itemInfo)) + } + + @Test + fun test_invalid_widget_deleted() { + val itemInfo = + widgetItemInfo(false).apply { + providerName = ComponentName(providerName.packageName, "invalid_provider_name") + } + val view = + MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get() + assertNull(view) + verify(modelWriter).deleteItemFromDatabase(same(itemInfo), any()) + } + + @Test + fun test_normal_widget_inflated_UI() { + val providerInfo = TestViewHelpers.findWidgetProvider(false) + val id = widgetHolder.allocateAppWidgetId() + assertTrue( + WidgetManagerHelper(uiContext).bindAppWidgetIdIfAllowed(id, providerInfo, Bundle()) + ) + val itemInfo = LauncherAppWidgetInfo(id, providerInfo.provider) + itemInfo.spanX = 2 + itemInfo.spanY = 2 + + val view = + MAIN_EXECUTOR.submit(Callable { underTest.inflateItem(itemInfo, modelWriter) }).get() + + // Verify that the widget is automatically restored and a final widget is returned + assertTrue(view is LauncherAppWidgetHostView) + assertFalse(view is PendingAppWidgetHostView) + assertEquals(itemInfo, view!!.tag) + verifyNoMoreInteractions(modelWriter) + } + + private fun workspaceItemInfo() = + AppInfo( + uiContext, + uiContext + .getSystemService(LauncherApps::class.java)!! + .getActivityList(testContext.packageName, Process.myUserHandle())[0], + Process.myUserHandle() + ) + .makeWorkspaceItem(uiContext) + + private fun widgetItemInfo(hasConfig: Boolean) = + LauncherAppWidgetInfo(0, TestViewHelpers.findWidgetProvider(hasConfig).component).apply { + spanX = 2 + spanY = 2 + restoreStatus = FLAG_ID_NOT_VALID or FLAG_UI_NOT_READY + } +}