/*
 * Copyright (C) 2023 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.launcher3.taskbar.bubbles;

import static android.os.Process.THREAD_PRIORITY_BACKGROUND;

import static com.android.launcher3.util.Executors.MAIN_EXECUTOR;
import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_BOUNCER_SHOWING;
import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_IME_VISIBLE;
import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_NOTIFICATION_PANEL_EXPANDED;
import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_QUICK_SETTINGS_EXPANDED;
import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_STATUS_BAR_KEYGUARD_SHOWING;
import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_STATUS_BAR_KEYGUARD_SHOWING_OCCLUDED;

import android.annotation.BinderThread;
import android.annotation.Nullable;
import android.content.Context;
import android.graphics.Point;
import android.os.Bundle;
import android.os.SystemProperties;
import android.util.ArrayMap;
import android.util.Log;

import androidx.annotation.NonNull;

import com.android.launcher3.taskbar.TaskbarSharedState;
import com.android.launcher3.taskbar.bubbles.stashing.BubbleStashController;
import com.android.launcher3.util.Executors.SimpleThreadFactory;
import com.android.quickstep.SystemUiProxy;
import com.android.systemui.shared.system.QuickStepContract.SystemUiStateFlags;
import com.android.wm.shell.Flags;
import com.android.wm.shell.bubbles.IBubblesListener;
import com.android.wm.shell.shared.bubbles.BubbleBarLocation;
import com.android.wm.shell.shared.bubbles.BubbleBarUpdate;
import com.android.wm.shell.shared.bubbles.BubbleInfo;
import com.android.wm.shell.shared.bubbles.RemovedBubble;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

/**
 * This registers a listener with SysUIProxy to get information about changes to the bubble
 * stack state from WMShell (SysUI). The controller is also responsible for loading the necessary
 * information to render each of the bubbles & dispatches changes to
 * {@link BubbleBarViewController} which will then update {@link BubbleBarView} as needed.
 *
 * <p>For details around the behavior of the bubble bar, see {@link BubbleBarView}.
 */
public class BubbleBarController extends IBubblesListener.Stub {

    private static final String TAG = "BubbleBarController";
    private static final boolean DEBUG = false;

    /**
     * Determines whether bubbles can be shown in the bubble bar. This value updates when the
     * taskbar is recreated.
     *
     * @see #onTaskbarRecreated()
     */
    private static boolean sBubbleBarEnabled = Flags.enableBubbleBar()
            || SystemProperties.getBoolean("persist.wm.debug.bubble_bar", false);

    /** Whether showing bubbles in the launcher bubble bar is enabled. */
    public static boolean isBubbleBarEnabled() {
        return sBubbleBarEnabled;
    }

    /** Re-reads the value of the flag from SystemProperties when taskbar is recreated. */
    public static void onTaskbarRecreated() {
        sBubbleBarEnabled = Flags.enableBubbleBar()
                || SystemProperties.getBoolean("persist.wm.debug.bubble_bar", false);
    }

    private static final long MASK_HIDE_BUBBLE_BAR = SYSUI_STATE_BOUNCER_SHOWING
            | SYSUI_STATE_STATUS_BAR_KEYGUARD_SHOWING
            | SYSUI_STATE_STATUS_BAR_KEYGUARD_SHOWING_OCCLUDED
            | SYSUI_STATE_IME_VISIBLE
            | SYSUI_STATE_NOTIFICATION_PANEL_EXPANDED
            | SYSUI_STATE_QUICK_SETTINGS_EXPANDED;

    private static final long MASK_HIDE_HANDLE_VIEW = SYSUI_STATE_BOUNCER_SHOWING
            | SYSUI_STATE_STATUS_BAR_KEYGUARD_SHOWING
            | SYSUI_STATE_STATUS_BAR_KEYGUARD_SHOWING_OCCLUDED;

    private static final long MASK_SYSUI_LOCKED = SYSUI_STATE_BOUNCER_SHOWING
            | SYSUI_STATE_STATUS_BAR_KEYGUARD_SHOWING
            | SYSUI_STATE_STATUS_BAR_KEYGUARD_SHOWING_OCCLUDED;

    private final Context mContext;
    private final BubbleBarView mBarView;
    private final ArrayMap<String, BubbleBarBubble> mBubbles = new ArrayMap<>();
    private final ArrayMap<String, BubbleBarBubble> mSuppressedBubbles = new ArrayMap<>();

    private static final Executor BUBBLE_STATE_EXECUTOR = Executors.newSingleThreadExecutor(
            new SimpleThreadFactory("BubbleStateUpdates-", THREAD_PRIORITY_BACKGROUND));
    private final SystemUiProxy mSystemUiProxy;

    private BubbleBarItem mSelectedBubble;

    private TaskbarSharedState mSharedState;
    private BubbleBarViewController mBubbleBarViewController;
    private BubbleStashController mBubbleStashController;
    private Optional<BubbleStashedHandleViewController> mBubbleStashedHandleViewController;
    private BubblePinController mBubblePinController;
    private BubbleCreator mBubbleCreator;
    private BubbleBarLocationListener mBubbleBarLocationListener;

    // Cache last sent top coordinate to avoid sending duplicate updates to shell
    private int mLastSentBubbleBarTop;

    private boolean mIsImeVisible = false;

    /**
     * Similar to {@link BubbleBarUpdate} but rather than {@link BubbleInfo}s it uses
     * {@link BubbleBarBubble}s so that it can be used to update the views.
     */
    private static class BubbleBarViewUpdate {
        final boolean initialState;
        boolean expandedChanged;
        boolean expanded;
        boolean shouldShowEducation;
        String selectedBubbleKey;
        String suppressedBubbleKey;
        String unsuppressedBubbleKey;
        BubbleBarLocation bubbleBarLocation;
        List<RemovedBubble> removedBubbles;
        List<String> bubbleKeysInOrder;
        Point expandedViewDropTargetSize;
        boolean showOverflow;
        boolean showOverflowChanged;

        // These need to be loaded in the background
        BubbleBarBubble addedBubble;
        BubbleBarBubble updatedBubble;
        List<BubbleBarBubble> currentBubbles;

        BubbleBarViewUpdate(BubbleBarUpdate update) {
            initialState = update.initialState;
            expandedChanged = update.expandedChanged;
            expanded = update.expanded;
            shouldShowEducation = update.shouldShowEducation;
            selectedBubbleKey = update.selectedBubbleKey;
            suppressedBubbleKey = update.suppressedBubbleKey;
            unsuppressedBubbleKey = update.unsupressedBubbleKey;
            bubbleBarLocation = update.bubbleBarLocation;
            removedBubbles = update.removedBubbles;
            bubbleKeysInOrder = update.bubbleKeysInOrder;
            expandedViewDropTargetSize = update.expandedViewDropTargetSize;
            showOverflow = update.showOverflow;
            showOverflowChanged = update.showOverflowChanged;
        }
    }

    public BubbleBarController(Context context, BubbleBarView bubbleView) {
        mContext = context;
        mBarView = bubbleView; // Need the view for inflating bubble views.

        mSystemUiProxy = SystemUiProxy.INSTANCE.get(context);
    }

    public void onDestroy() {
        mSystemUiProxy.setBubblesListener(null);
        // Saves bubble bar state
        BubbleInfo[] bubbleInfoItems = new BubbleInfo[mBubbles.size()];
        mBubbles.values().forEach(bubbleBarBubble -> {
            int index = mBubbleBarViewController.bubbleViewIndex(bubbleBarBubble.getView());
            if (index < 0 || index >= bubbleInfoItems.length) {
                Log.e(TAG, "Found improper index: " + index + " for " + bubbleBarBubble);
            } else {
                bubbleInfoItems[index] = bubbleBarBubble.getInfo();
            }
        });
        mSharedState.bubbleInfoItems = Arrays.asList(bubbleInfoItems);
        mSharedState.suppressedBubbleInfoItems = new ArrayList<>(mSuppressedBubbles.size());
        for (int i = 0; i < mSuppressedBubbles.size(); i++) {
            mSharedState.suppressedBubbleInfoItems.add(mSuppressedBubbles.valueAt(i).getInfo());
        }
    }

    /** Initializes controllers. */
    public void init(BubbleControllers bubbleControllers,
            BubbleBarLocationListener bubbleBarLocationListener,
            TaskbarSharedState sharedState) {
        mSharedState = sharedState;
        mBubbleBarViewController = bubbleControllers.bubbleBarViewController;
        mBubbleStashController = bubbleControllers.bubbleStashController;
        mBubbleStashedHandleViewController = bubbleControllers.bubbleStashedHandleViewController;
        mBubblePinController = bubbleControllers.bubblePinController;
        mBubbleCreator = bubbleControllers.bubbleCreator;
        mBubbleBarLocationListener = bubbleBarLocationListener;

        bubbleControllers.runAfterInit(() -> {
            restoreSavedState(sharedState);
            mBubbleBarViewController.setHiddenForBubbles(
                    !sBubbleBarEnabled || mBubbles.isEmpty());
            mBubbleStashedHandleViewController.ifPresent(
                    controller -> controller.setHiddenForBubbles(
                            !sBubbleBarEnabled || mBubbles.isEmpty()));
            mBubbleBarViewController.setUpdateSelectedBubbleAfterCollapse(
                    key -> setSelectedBubbleInternal(mBubbles.get(key)));
            mBubbleBarViewController.setBoundsChangeListener(this::onBubbleBarBoundsChanged);
            mBubbleBarLocationListener.onBubbleBarLocationUpdated(
                    mBubbleBarViewController.getBubbleBarLocation());
            if (sBubbleBarEnabled) {
                mSystemUiProxy.setBubblesListener(this);
            }
        });
    }

    /**
     * Updates the bubble bar, handle bar, and stash controllers based on sysui state flags.
     */
    public void updateStateForSysuiFlags(@SystemUiStateFlags long flags) {
        boolean hideBubbleBar = (flags & MASK_HIDE_BUBBLE_BAR) != 0;
        mBubbleBarViewController.setHiddenForSysui(hideBubbleBar);

        boolean hideHandleView = (flags & MASK_HIDE_HANDLE_VIEW) != 0;
        mBubbleStashedHandleViewController.ifPresent(
                controller -> controller.setHiddenForSysui(hideHandleView));

        boolean sysuiLocked = (flags & MASK_SYSUI_LOCKED) != 0;
        mBubbleStashController.setSysuiLocked(sysuiLocked);
        mIsImeVisible = (flags & SYSUI_STATE_IME_VISIBLE) != 0;
        if (mIsImeVisible) {
            mBubbleBarViewController.onImeVisible();
        }
    }

    //
    // Bubble data changes
    //

    @BinderThread
    @Override
    public void onBubbleStateChange(Bundle bundle) {
        bundle.setClassLoader(BubbleBarUpdate.class.getClassLoader());
        BubbleBarUpdate update = bundle.getParcelable("update", BubbleBarUpdate.class);
        BubbleBarViewUpdate viewUpdate = new BubbleBarViewUpdate(update);
        if (update.addedBubble != null
                || update.updatedBubble != null
                || !update.currentBubbleList.isEmpty()) {
            // We have bubbles to load
            BUBBLE_STATE_EXECUTOR.execute(() -> {
                if (update.addedBubble != null) {
                    viewUpdate.addedBubble = mBubbleCreator.populateBubble(mContext,
                            update.addedBubble,
                            mBarView,
                            null /* existingBubble */);
                }
                if (update.updatedBubble != null) {
                    BubbleBarBubble existingBubble = mBubbles.get(update.updatedBubble.getKey());
                    viewUpdate.updatedBubble =
                            mBubbleCreator.populateBubble(mContext, update.updatedBubble,
                                    mBarView,
                                    existingBubble);
                }
                if (update.currentBubbleList != null && !update.currentBubbleList.isEmpty()) {
                    List<BubbleBarBubble> currentBubbles = new ArrayList<>();
                    for (int i = 0; i < update.currentBubbleList.size(); i++) {
                        BubbleBarBubble b = mBubbleCreator.populateBubble(mContext,
                                update.currentBubbleList.get(i), mBarView,
                                null /* existingBubble */);
                        currentBubbles.add(b);
                    }
                    viewUpdate.currentBubbles = currentBubbles;
                }
                MAIN_EXECUTOR.execute(() -> applyViewChanges(viewUpdate));
            });
        } else {
            // No bubbles to load, immediately apply the changes.
            BUBBLE_STATE_EXECUTOR.execute(
                    () -> MAIN_EXECUTOR.execute(() -> applyViewChanges(viewUpdate)));
        }
    }

    private void restoreSavedState(TaskbarSharedState sharedState) {
        if (sharedState.bubbleBarLocation != null) {
            updateBubbleBarLocationInternal(sharedState.bubbleBarLocation);
        }
        restoreSavedBubbles(sharedState.bubbleInfoItems);
        restoreSuppressed(sharedState.suppressedBubbleInfoItems);
    }

    private void restoreSavedBubbles(List<BubbleInfo> bubbleInfos) {
        if (bubbleInfos == null || bubbleInfos.isEmpty()) return;
        // Iterate in reverse because new bubbles are added in front and the list is in order.
        for (int i = bubbleInfos.size() - 1; i >= 0; i--) {
            BubbleBarBubble bubble = mBubbleCreator.populateBubble(mContext,
                    bubbleInfos.get(i), mBarView, /* existingBubble = */ null);
            if (bubble == null) {
                Log.e(TAG, "Could not instantiate BubbleBarBubble for " + bubbleInfos.get(i));
                continue;
            }
            addBubbleInternally(bubble, /* isExpanding= */ false, /* suppressAnimation= */ true);
        }
    }

    private void restoreSuppressed(List<BubbleInfo> bubbleInfos) {
        if (bubbleInfos == null || bubbleInfos.isEmpty()) return;
        for (BubbleInfo bubbleInfo : bubbleInfos.reversed()) {
            BubbleBarBubble bb = mBubbleCreator.populateBubble(mContext, bubbleInfo,
                    mBarView, /* existingBubble= */
                    null);
            if (bb != null) {
                mSuppressedBubbles.put(bb.getKey(), bb);
            }
        }
    }

    private void applyViewChanges(BubbleBarViewUpdate update) {
        final boolean isCollapsed = (update.expandedChanged && !update.expanded)
                || (!update.expandedChanged && !mBubbleBarViewController.isExpanded());
        final boolean isExpanding = update.expandedChanged && update.expanded;
        // don't animate bubbles if this is the initial state because we may be unfolding or
        // enabling gesture nav. also suppress animation if the bubble bar is hidden for sysui e.g.
        // the shade is open, or we're locked.
        final boolean suppressAnimation =
                update.initialState || mBubbleBarViewController.isHiddenForSysui() || mIsImeVisible;

        if (update.initialState && mSharedState.hasSavedBubbles()) {
            // clear restored state
            mBubbleBarViewController.removeAllBubbles();
            mBubbles.clear();
            mBubbleBarViewController.showOverflow(update.showOverflow);
        }

        if (update.addedBubble != null) {
            mBubbles.put(update.addedBubble.getKey(), update.addedBubble);
        }
        BubbleBarBubble bubbleToSelect = null;
        if (update.selectedBubbleKey != null) {
            if (mSelectedBubble == null
                    || !update.selectedBubbleKey.equals(mSelectedBubble.getKey())) {
                BubbleBarBubble newlySelected = mBubbles.get(update.selectedBubbleKey);
                if (newlySelected != null) {
                    bubbleToSelect = newlySelected;
                } else {
                    Log.w(TAG, "trying to select bubble that doesn't exist:"
                            + update.selectedBubbleKey);
                }
            }
        }
        if (Flags.enableOptionalBubbleOverflow()
                && update.showOverflowChanged && !update.showOverflow && update.addedBubble != null
                && update.removedBubbles.isEmpty()
                && !mBubbles.isEmpty()) {
            // A bubble was added from the overflow (& now it's empty / not showing)
            mBubbleBarViewController.removeOverflowAndAddBubble(update.addedBubble, bubbleToSelect);
        } else if (update.addedBubble != null && update.removedBubbles.size() == 1) {
            // we're adding and removing a bubble at the same time. handle this as a single update.
            RemovedBubble removedBubble = update.removedBubbles.get(0);
            BubbleBarBubble bubbleToRemove = mBubbles.remove(removedBubble.getKey());
            boolean showOverflow = update.showOverflowChanged && update.showOverflow;
            if (bubbleToRemove != null) {
                mBubbleBarViewController.addBubbleAndRemoveBubble(update.addedBubble,
                        bubbleToRemove, bubbleToSelect, isExpanding, suppressAnimation,
                        showOverflow);
            } else {
                mBubbleBarViewController.addBubble(update.addedBubble, isExpanding,
                        suppressAnimation, bubbleToSelect);
                Log.w(TAG, "trying to remove bubble that doesn't exist: " + removedBubble.getKey());
            }
        } else {
            boolean overflowNeedsToBeAdded = Flags.enableOptionalBubbleOverflow()
                    && update.showOverflowChanged && update.showOverflow;
            if (!update.removedBubbles.isEmpty()) {
                for (int i = 0; i < update.removedBubbles.size(); i++) {
                    RemovedBubble removedBubble = update.removedBubbles.get(i);
                    BubbleBarBubble bubble = mBubbles.remove(removedBubble.getKey());
                    if (bubble != null && overflowNeedsToBeAdded) {
                        // First removal, show the overflow
                        overflowNeedsToBeAdded = false;
                        mBubbleBarViewController.addOverflowAndRemoveBubble(bubble, bubbleToSelect);
                    } else if (bubble != null) {
                        mBubbleBarViewController.removeBubble(bubble);
                    } else {
                        Log.w(TAG, "trying to remove bubble that doesn't exist: "
                                + removedBubble.getKey());
                    }
                }
            }
            if (update.addedBubble != null) {
                mBubbleBarViewController.addBubble(update.addedBubble, isExpanding,
                        suppressAnimation, bubbleToSelect);
            }
            if (Flags.enableOptionalBubbleOverflow()
                    && update.showOverflowChanged
                    && update.showOverflow != mBubbleBarViewController.isOverflowAdded()) {
                mBubbleBarViewController.showOverflow(update.showOverflow);
            }
        }

        // if a bubble was updated upstream, but removed before the update was received, add it back
        if (update.updatedBubble != null && !mBubbles.containsKey(update.updatedBubble.getKey())) {
            addBubbleInternally(update.updatedBubble, isExpanding, suppressAnimation);
        }

        if (update.addedBubble != null && isCollapsed && bubbleToSelect == null) {
            // If we're collapsed, the most recently added bubble will be selected.
            bubbleToSelect = update.addedBubble;
        }

        if (update.currentBubbles != null && !update.currentBubbles.isEmpty()) {
            // Iterate in reverse because new bubbles are added in front and the list is in order.
            for (int i = update.currentBubbles.size() - 1; i >= 0; i--) {
                BubbleBarBubble bubble = update.currentBubbles.get(i);
                if (bubble != null) {
                    addBubbleInternally(bubble, isExpanding, suppressAnimation);
                    if (isCollapsed && bubbleToSelect == null) {
                        // If we're collapsed, the most recently added bubble will be selected.
                        bubbleToSelect = bubble;
                    }
                } else {
                    Log.w(TAG, "trying to add bubble but null after loading! "
                            + update.addedBubble.getKey());
                }
            }
        }
        if (Flags.enableOptionalBubbleOverflow() && update.initialState && update.showOverflow) {
            mBubbleBarViewController.showOverflow(true);
        }

        if (update.suppressedBubbleKey != null) {
            BubbleBarBubble bb = mBubbles.remove(update.suppressedBubbleKey);
            if (bb != null) {
                mSuppressedBubbles.put(update.suppressedBubbleKey, bb);
                mBubbleBarViewController.removeBubble(bb);
            }
        }
        if (update.unsuppressedBubbleKey != null) {
            BubbleBarBubble bb = mSuppressedBubbles.remove(update.unsuppressedBubbleKey);
            if (bb != null) {
                // Unsuppressing an existing bubble should not cause the bar to expand or animate
                addBubbleInternally(bb, /* isExpanding= */ false, /* suppressAnimation= */ true);
                if (mBubbleBarViewController.isHiddenForNoBubbles()) {
                    mBubbleBarViewController.setHiddenForBubbles(false);
                }
            }
        }

        // Update the visibility if this is the initial state, if there are no bubbles, or if the
        // animation is suppressed.
        // If this is the initial bubble, the bubble bar will become visible as part of the
        // animation.
        if (update.initialState || mBubbles.isEmpty() || suppressAnimation) {
            mBubbleBarViewController.setHiddenForBubbles(mBubbles.isEmpty());
        }
        mBubbleStashedHandleViewController.ifPresent(
                controller -> controller.setHiddenForBubbles(mBubbles.isEmpty()));

        if (mBubbles.isEmpty()) {
            // all bubbles were removed. clear the selected bubble
            mSelectedBubble = null;
        }

        if (update.updatedBubble != null) {
            // Updates mean the dot state may have changed; any other changes were updated in
            // the populateBubble step.
            BubbleBarBubble bb = mBubbles.get(update.updatedBubble.getKey());
            if (suppressAnimation) {
                // since we're not animating this update, we should update the dot visibility here.
                bb.getView().updateDotVisibility(/* animate= */ false);
            } else {
                mBubbleBarViewController.animateBubbleNotification(
                        bb, /* isExpanding= */ false, /* isUpdate= */ true);
            }
        }
        if (update.bubbleKeysInOrder != null && !update.bubbleKeysInOrder.isEmpty()) {
            // Create the new list
            List<BubbleBarBubble> newOrder = update.bubbleKeysInOrder.stream()
                    .map(mBubbles::get).filter(Objects::nonNull).toList();
            if (!newOrder.isEmpty()) {
                mBubbleBarViewController.reorderBubbles(newOrder);
            }
        }
        if (bubbleToSelect != null) {
            setSelectedBubbleInternal(bubbleToSelect);
        }
        if (update.shouldShowEducation) {
            mBubbleBarViewController.prepareToShowEducation();
        }
        if (update.expandedChanged) {
            if (update.expanded != mBubbleBarViewController.isExpanded()) {
                mBubbleBarViewController.setExpandedFromSysui(update.expanded);
            } else {
                Log.w(TAG, "expansion was changed but is the same");
            }
        }
        if (update.bubbleBarLocation != null) {
            mSharedState.bubbleBarLocation = update.bubbleBarLocation;
            if (update.bubbleBarLocation != mBubbleBarViewController.getBubbleBarLocation()) {
                updateBubbleBarLocationInternal(update.bubbleBarLocation);
            }
        }
        if (update.expandedViewDropTargetSize != null) {
            mBubblePinController.setDropTargetSize(update.expandedViewDropTargetSize);
        }
    }

    /**
     * Removes the given bubble from the backing list of bubbles after it was dismissed by the user.
     */
    public void onBubbleDismissed(BubbleView bubble) {
        mBubbles.remove(bubble.getBubble().getKey());
    }

    /** Tells WMShell to show the currently selected bubble. */
    public void showSelectedBubble() {
        if (getSelectedBubbleKey() != null) {
            mLastSentBubbleBarTop = mBarView.getRestingTopPositionOnScreen();
            mSystemUiProxy.showBubble(getSelectedBubbleKey(), mLastSentBubbleBarTop);
        } else {
            Log.w(TAG, "Trying to show the selected bubble but it's null");
        }
    }

    /** Updates the currently selected bubble for launcher views and tells WMShell to show it. */
    public void showAndSelectBubble(BubbleBarItem b) {
        if (DEBUG) Log.w(TAG, "showingSelectedBubble: " + b.getKey());
        setSelectedBubbleInternal(b);
        showSelectedBubble();
    }

    /**
     * Sets the bubble that should be selected. This notifies the views, it does not notify
     * WMShell that the selection has changed, that should go through either
     * {@link #showSelectedBubble()} or {@link #showAndSelectBubble(BubbleBarItem)}.
     */
    private void setSelectedBubbleInternal(BubbleBarItem b) {
        if (!Objects.equals(b, mSelectedBubble)) {
            if (DEBUG) Log.w(TAG, "selectingBubble: " + b.getKey());
            mSelectedBubble = b;
            mBubbleBarViewController.updateSelectedBubble(mSelectedBubble);
        }
    }

    /**
     * Returns the selected bubble or null if no bubble is selected.
     */
    @Nullable
    public String getSelectedBubbleKey() {
        if (mSelectedBubble != null) {
            return mSelectedBubble.getKey();
        }
        return null;
    }

    /**
     * Set a new bubble bar location.
     * <p>
     * Updates the value locally in Launcher and in WMShell.
     */
    public void updateBubbleBarLocation(BubbleBarLocation location,
            @BubbleBarLocation.UpdateSource int source) {
        updateBubbleBarLocationInternal(location);
        mSystemUiProxy.setBubbleBarLocation(location, source);
    }

    private void updateBubbleBarLocationInternal(BubbleBarLocation location) {
        mBubbleBarViewController.setBubbleBarLocation(location);
        mBubbleStashController.setBubbleBarLocation(location);
        mBubbleBarLocationListener.onBubbleBarLocationUpdated(location);
    }

    @Override
    public void animateBubbleBarLocation(BubbleBarLocation bubbleBarLocation) {
        MAIN_EXECUTOR.execute(
                () -> {
                    mBubbleBarViewController.animateBubbleBarLocation(bubbleBarLocation);
                    mBubbleBarLocationListener.onBubbleBarLocationAnimated(bubbleBarLocation);
                });
    }

    @Override
    public void onDragItemOverBubbleBarDragZone(@NonNull BubbleBarLocation bubbleBarLocation) {
        MAIN_EXECUTOR.execute(() -> {
            mBubbleBarViewController.onDragItemOverBubbleBarDragZone(bubbleBarLocation);
            if (mBubbleBarViewController.isLocationUpdatedForDropTarget()) {
                mBubbleBarLocationListener.onBubbleBarLocationAnimated(bubbleBarLocation);
            }
        });
    }

    @Override
    public void onItemDraggedOutsideBubbleBarDropZone() {
        MAIN_EXECUTOR.execute(() -> {
            if (mBubbleBarViewController.isLocationUpdatedForDropTarget()) {
                BubbleBarLocation original = mBubbleBarViewController.getBubbleBarLocation();
                mBubbleBarLocationListener.onBubbleBarLocationAnimated(original);
            }
            mBubbleBarViewController.onItemDraggedOutsideBubbleBarDropZone();
        });
    }

    /** Notifies WMShell to show the expanded view. */
    void showExpandedView() {
        mSystemUiProxy.showExpandedView();
    }

    //
    // Loading data for the bubbles
    //

    private void onBubbleBarBoundsChanged() {
        int newTop = mBarView.getRestingTopPositionOnScreen();
        if (newTop != mLastSentBubbleBarTop) {
            mLastSentBubbleBarTop = newTop;
            mSystemUiProxy.updateBubbleBarTopOnScreen(newTop);
        }
    }

    private void addBubbleInternally(BubbleBarBubble bubble, boolean isExpanding,
            boolean suppressAnimation) {
        mBubbles.put(bubble.getKey(), bubble);
        mBubbleBarViewController.addBubble(bubble, isExpanding,
                suppressAnimation, /* bubbleToSelect = */ null);
    }

    /** Listener of {@link BubbleBarLocation} updates. */
    public interface BubbleBarLocationListener {

        /** Called when {@link BubbleBarLocation} is animated, but change is not yet final. */
        void onBubbleBarLocationAnimated(BubbleBarLocation location);

        /** Called when {@link BubbleBarLocation} is updated permanently. */
        void onBubbleBarLocationUpdated(BubbleBarLocation location);
    }
}
