/*
 * Copyright (C) 2014 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.systemui.statusbar.notification.stack;

import static android.os.Trace.TRACE_TAG_APP;
import static android.view.MotionEvent.ACTION_CANCEL;
import static android.view.MotionEvent.ACTION_DOWN;
import static android.view.MotionEvent.ACTION_POINTER_DOWN;
import static android.view.MotionEvent.ACTION_UP;

import static com.android.app.tracing.TrackGroupUtils.trackGroup;
import static com.android.internal.jank.InteractionJankMonitor.CUJ_NOTIFICATION_SHADE_SCROLL_FLING;
import static com.android.internal.jank.InteractionJankMonitor.CUJ_SHADE_CLEAR_ALL;
import static com.android.systemui.Flags.magneticNotificationSwipes;
import static com.android.systemui.Flags.physicalNotificationMovement;
import static com.android.systemui.statusbar.notification.stack.NotificationPriorityBucketKt.BUCKET_NEWS;
import static com.android.systemui.statusbar.notification.stack.NotificationPriorityBucketKt.BUCKET_PROMO;
import static com.android.systemui.statusbar.notification.stack.NotificationPriorityBucketKt.BUCKET_RECS;
import static com.android.systemui.statusbar.notification.stack.NotificationPriorityBucketKt.BUCKET_SILENT;
import static com.android.systemui.statusbar.notification.stack.NotificationPriorityBucketKt.BUCKET_SOCIAL;
import static com.android.systemui.statusbar.notification.stack.StackStateAnimator.ANIMATION_DURATION_SWIPE;
import static com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent.SCROLL_DOWN;
import static com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent.SCROLL_UP;
import static com.android.systemui.util.DumpUtilsKt.println;
import static com.android.systemui.util.DumpUtilsKt.visibilityString;

import static java.lang.annotation.RetentionPolicy.SOURCE;

import android.annotation.ColorInt;
import android.annotation.FloatRange;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Outline;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.RenderEffect;
import android.graphics.RenderNode;
import android.graphics.Shader;
import android.os.Bundle;
import android.os.SystemClock;
import android.os.Trace;
import android.util.AttributeSet;
import android.util.IndentingPrintWriter;
import android.util.Log;
import android.util.MathUtils;
import android.view.DisplayCutout;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.ViewParent;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.view.WindowInsetsAnimation;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import android.widget.OverScroller;
import android.widget.ScrollView;

import com.android.app.animation.Interpolators;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.jank.InteractionJankMonitor;
import com.android.internal.policy.SystemBarUtils;
import com.android.keyguard.BouncerPanelExpansionCalculator;
import com.android.keyguard.KeyguardSliceView;
import com.android.systemui.Dependency;
import com.android.systemui.Dumpable;
import com.android.systemui.ExpandHelper;
import com.android.systemui.flags.FeatureFlags;
import com.android.systemui.flags.Flags;
import com.android.systemui.qs.flags.QSComposeFragment;
import com.android.systemui.res.R;
import com.android.systemui.scene.shared.flag.SceneContainerFlag;
import com.android.systemui.shade.QSHeaderBoundsProvider;
import com.android.systemui.shade.TouchLogger;
import com.android.systemui.statusbar.NotificationShelf;
import com.android.systemui.statusbar.StatusBarState;
import com.android.systemui.statusbar.headsup.shared.StatusBarNoHunBehavior;
import com.android.systemui.statusbar.notification.ColorUpdateLogger;
import com.android.systemui.statusbar.notification.FakeShadowView;
import com.android.systemui.statusbar.notification.LaunchAnimationParameters;
import com.android.systemui.statusbar.notification.NotificationTransitionAnimatorController;
import com.android.systemui.statusbar.notification.NotificationUtils;
import com.android.systemui.statusbar.notification.PhysicsPropertyAnimator;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.collection.render.GroupExpansionManager;
import com.android.systemui.statusbar.notification.collection.render.GroupMembershipManager;
import com.android.systemui.statusbar.notification.emptyshade.ui.shared.flag.ShowIconInEmptyShade;
import com.android.systemui.statusbar.notification.emptyshade.ui.view.EmptyShadeIconView;
import com.android.systemui.statusbar.notification.emptyshade.ui.view.EmptyShadeView;
import com.android.systemui.statusbar.notification.footer.ui.view.FooterView;
import com.android.systemui.statusbar.notification.headsup.HeadsUpAnimationEvent;
import com.android.systemui.statusbar.notification.headsup.HeadsUpAnimator;
import com.android.systemui.statusbar.notification.headsup.HeadsUpTouchHelper;
import com.android.systemui.statusbar.notification.headsup.HeadsUpUtil;
import com.android.systemui.statusbar.notification.promoted.PromotedNotificationUi;
import com.android.systemui.statusbar.notification.row.ActivatableNotificationView;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.notification.row.ExpandableView;
import com.android.systemui.statusbar.notification.row.StackScrollerDecorView;
import com.android.systemui.statusbar.notification.shared.NotificationBundleUi;
import com.android.systemui.statusbar.notification.shared.NotificationHeadsUpCycling;
import com.android.systemui.statusbar.notification.shared.NotificationMinimalism;
import com.android.systemui.statusbar.notification.shared.NotificationThrottleHun;
import com.android.systemui.statusbar.notification.stack.shared.model.AccessibilityScrollEvent;
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimBounds;
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrimShape;
import com.android.systemui.statusbar.notification.stack.shared.model.ShadeScrollState;
import com.android.systemui.statusbar.notification.stack.ui.view.NotificationScrollView;
import com.android.systemui.statusbar.phone.HeadsUpAppearanceController;
import com.android.systemui.statusbar.policy.ScrollAdapter;
import com.android.systemui.statusbar.policy.SplitShadeStateController;
import com.android.systemui.util.Assert;
import com.android.systemui.util.ColorUtilKt;
import com.android.systemui.util.DumpUtilsKt;
import com.android.systemui.util.ListenerSet;
import com.android.systemui.util.state.DownstreamObservableState;
import com.android.systemui.util.state.ObservableState;

import com.google.errorprone.annotations.CompileTimeConstant;

import kotlin.Unit;
import kotlin.jvm.functions.Function1;

import kotlinx.coroutines.DisposableHandle;

import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.function.BiConsumer;
import java.util.function.Consumer;

/**
 * A layout which handles a dynamic amount of notifications and presents them in a scrollable stack.
 */
public class NotificationStackScrollLayout
        extends ViewGroup
        implements Dumpable, NotificationScrollView {
    public static final float BACKGROUND_ALPHA_DIMMED = 0.7f;
    private static final String TAG = "StackScroller";
    private static final boolean SPEW = Log.isLoggable(TAG, Log.VERBOSE);

    private boolean mShadeNeedsToClose = false;

    @VisibleForTesting
    static final float RUBBER_BAND_FACTOR_NORMAL = 0.1f;
    private static final float RUBBER_BAND_FACTOR_AFTER_EXPAND = 0.15f;
    private static final float RUBBER_BAND_FACTOR_ON_PANEL_EXPAND = 0.21f;
    /**
     * Sentinel value for no current active pointer. Used by {@link #mActivePointerId}.
     */
    private static final int INVALID_POINTER = -1;
    private boolean mKeyguardBypassEnabled;

    private final ExpandHelper mExpandHelper;
    private NotificationSwipeHelper mSwipeHelper;
    private int mCurrentStackHeight = Integer.MAX_VALUE;
    private boolean mHighPriorityBeforeSpeedBump;

    private float mExpandedHeight;
    private int mOwnScrollY;
    private int mMaxLayoutHeight;

    private VelocityTracker mVelocityTracker;
    private OverScrollerInterface mScroller;

    private Runnable mFinishScrollingCallback;
    private int mTouchSlop;
    private float mSlopMultiplier;
    private int mMinimumVelocity;
    private int mMaximumVelocity;
    private int mOverflingDistance;
    private float mMaxOverScroll;
    private boolean mIsBeingDragged;
    private boolean mSendingTouchesToSceneFramework;
    private int mLastMotionY;
    private int mDownX;
    private int mActivePointerId = INVALID_POINTER;
    private boolean mTouchIsClick;
    private float mInitialTouchX;
    private float mInitialTouchY;


    // Record the pointerId for the MotionEvents that's outside of drawBounds
    private int mOutBoundsEventId = -1;

    private final boolean mDebugLines;
    private Paint mDebugPaint;
    /**
     * Used to track the Y positions that were already used to draw debug text labels.
     */
    private Set<Integer> mDebugTextUsedYPositions;
    private final boolean mDebugRemoveAnimation;
    private int mContentHeight;
    private float mIntrinsicContentHeight;
    private int mPaddingBetweenElements;
    private int mMinimumSpacingBetweenChildren;
    private int mMaxTopPadding;
    private boolean mAnimateNextTopPaddingChange;
    private int mBottomPadding;
    @VisibleForTesting
    // mImeInset=0 when IME is hidden
    int mImeInset = 0;
    private float mQsExpansionFraction;
    private final int mSplitShadeMinContentHeight;
    private String mLastUpdateSidePaddingDumpString;
    private long mLastUpdateSidePaddingElapsedRealtime;
    private String mLastInitViewDumpString;
    private long mLastInitViewElapsedRealtime;

    private final HeadsUpAnimator mHeadsUpAnimator;
    /**
     * The algorithm which calculates the properties for our children
     */
    private final StackScrollAlgorithm mStackScrollAlgorithm;
    private final AmbientState mAmbientState;
    private final ScrollViewFields mScrollViewFields = new ScrollViewFields();

    private final GroupMembershipManager mGroupMembershipManager;
    private final GroupExpansionManager mGroupExpansionManager;
    private final HashSet<ExpandableView> mChildrenToAddAnimated = new HashSet<>();
    private final ArrayList<View> mAddedHeadsUpChildren = new ArrayList<>();
    private final ArrayList<ExpandableView> mChildrenToRemoveAnimated = new ArrayList<>();
    private final ArrayList<ExpandableView> mChildrenChangingPositions = new ArrayList<>();
    private final HashSet<View> mFromMoreCardAdditions = new HashSet<>();
    private final ArrayList<AnimationEvent> mAnimationEvents = new ArrayList<>();
    private final ArrayList<View> mSwipedOutViews = new ArrayList<>();
    private NotificationStackSizeCalculator mNotificationStackSizeCalculator;
    private final StackStateAnimator mStateAnimator;
    // TODO(b/332732878): call setAnimationsEnabled with scene container enabled, then remove this
    private boolean mAnimationsEnabled = SceneContainerFlag.isEnabled();
    private boolean mChangePositionInProgress;
    private boolean mChildTransferInProgress;

    private int mSpeedBumpIndex = -1;
    private boolean mSpeedBumpIndexDirty = true;

    /**
     * The raw amount of the overScroll on the top, which is not rubber-banded.
     */
    private float mOverScrolledTopPixels;

    /**
     * The raw amount of the overScroll on the bottom, which is not rubber-banded.
     */
    private float mOverScrolledBottomPixels;
    private final ListenerSet<Runnable> mStackHeightChangedListeners = new ListenerSet<>();
    private final ListenerSet<Runnable> mHeadsUpHeightChangedListeners = new ListenerSet<>();
    private OnNotificationLocationsChangedListener mLocationsChangedListener;
    private OnOverscrollTopChangedListener mOverscrollTopChangedListener;
    private ExpandableView.OnHeightChangedListener mOnHeightChangedListener;
    private Runnable mOnHeightChangedRunnable;
    private Runnable mOnKeyguardTopLevelNotificationRemovedRunnable;
    private OnEmptySpaceClickListener mOnEmptySpaceClickListener;
    private boolean mNeedsAnimation;
    private boolean mTopPaddingNeedsAnimation;
    private boolean mHideSensitiveNeedsAnimation;
    private boolean mActivateNeedsAnimation;
    private boolean mGoToFullShadeNeedsAnimation;
    private boolean mIsExpanded = true;
    private boolean mChildrenUpdateRequested;
    private boolean mIsExpansionChanging;
    private boolean mPanelTracking;
    private boolean mExpandingNotification;
    private boolean mExpandedInThisMotion;
    private boolean mShouldShowShelfOnly;
    protected boolean mScrollingEnabled;
    protected FooterView mFooterView;
    protected StackScrollerDecorView mEmptyShadeView;
    private boolean mClearAllInProgress;
    private boolean mFlingAfterUpEvent;
    /**
     * Was the scroller scrolled to the top when the down motion was observed?
     */
    private boolean mScrolledToTopOnFirstDown;
    /**
     * The minimal amount of over scroll which is needed in order to switch to the quick settings
     * when over scrolling on a expanded card.
     */
    private float mMinTopOverScrollToEscape;
    private int mIntrinsicPadding;
    private float mTopPaddingOverflow;
    private boolean mDontReportNextOverScroll;
    private boolean mDontClampNextScroll;
    private boolean mNeedViewResizeAnimation;
    private ExpandableView mExpandedGroupView;
    private boolean mEverythingNeedsAnimation;

    /**
     * The maximum scrollPosition which we are allowed to reach when a notification was expanded.
     * This is needed to avoid scrolling too far after the notification was collapsed in the same
     * motion.
     */
    private int mMaxScrollAfterExpand;
    boolean mCheckForLeavebehind;

    /**
     * Should in this touch motion only be scrolling allowed? It's true when the scroller was
     * animating.
     */
    private boolean mOnlyScrollingInThisMotion;
    private boolean mDisallowDismissInThisMotion;
    private boolean mDisallowScrollingInThisMotion;
    private long mGoToFullShadeDelay;
    private final ViewTreeObserver.OnPreDrawListener mChildrenUpdater
            = new ViewTreeObserver.OnPreDrawListener() {
        @Override
        public boolean onPreDraw() {
            if (SceneContainerFlag.isEnabled()) {
                getViewTreeObserver().removeOnPreDrawListener(this);
                return true;
            }
            updateForcedScroll();
            updateChildren();
            mChildrenUpdateRequested = false;
            getViewTreeObserver().removeOnPreDrawListener(this);
            return true;
        }
    };
    private NotificationStackScrollLogger mLogger;
    private Runnable mResetUserExpandedStatesRunnable;
    private final int[] mTempInt2 = new int[2];
    private final float[] mTempFloat2 = new float[2];
    private final HashSet<Runnable> mAnimationFinishedRunnables = new HashSet<>();
    private final HashSet<ExpandableView> mClearTransientViewsWhenFinished = new HashSet<>();
    private final Map<ExpandableNotificationRow, HeadsUpAnimationEvent> mHeadsUpChangeAnimations
            = new HashMap<>();
    private boolean mForceNoOverlappingRendering;
    private final ArrayList<ExpandableNotificationRow> mTmpHeadsUpChangeAnimations =
            new ArrayList<>();
    private boolean mAnimationRunning;
    private final ViewTreeObserver.OnPreDrawListener mRunningAnimationUpdater
            = new ViewTreeObserver.OnPreDrawListener() {
        @Override
        public boolean onPreDraw() {
            onPreDrawDuringAnimation();
            return true;
        }
    };
    private final NotificationSection[] mSections;
    private final ArrayList<ExpandableView> mTmpSortedChildren = new ArrayList<>();
    private final ArrayList<ExpandableView> mTmpNonOverlapChildren = new ArrayList<>();
    private final ArrayDeque<ExpandableView> mTmpStack = new ArrayDeque<>();
    protected ViewGroup mQsHeader;

    @Nullable
    private QSHeaderBoundsProvider mQSHeaderBoundsProvider;

    // Rect of QsHeader. Kept as a field just to avoid creating a new one each time.
    private final Rect mQsHeaderBound = new Rect();
    private boolean mContinuousShadowUpdate;
    private final ViewTreeObserver.OnPreDrawListener mShadowUpdater = () -> {
        updateViewShadows();
        return true;
    };
    private final Comparator<ExpandableView> mViewPositionComparator = (view, otherView) -> {
        float endY = view.getTranslationY() + view.getActualHeight();
        float otherEndY = otherView.getTranslationY() + otherView.getActualHeight();
        // Return zero when the two notifications end at the same location
        return Float.compare(endY, otherEndY);
    };
    private final Comparator<ExpandableView> mNotGoneIndexComparator = (view, otherView) -> {
        int compare = Integer.compare(view.getViewState().notGoneIndex,
                otherView.getViewState().notGoneIndex);
        if (compare != 0) {
            return compare;
        }
        // same not gone index. let's prefer transient views to regular ones, since they are
        // usually above
        boolean firstTransient = view.getTransientContainer() != null;
        boolean secondIsTransient = otherView.getTransientContainer() != null;
        return -1 * Boolean.compare(firstTransient, secondIsTransient);
    };

    private final ViewOutlineProvider mOutlineProvider = new ViewOutlineProvider() {
        @Override
        public void getOutline(View view, Outline outline) {
            if (mAmbientState.isHiddenAtAll()) {
                float xProgress = mHideXInterpolator.getInterpolation(
                        (1 - mLinearHideAmount) * mBackgroundXFactor);
                outline.setRoundRect(mBackgroundAnimationRect,
                        MathUtils.lerp(mCornerRadius / 2.0f, mCornerRadius,
                                xProgress));
                outline.setAlpha(1.0f - mAmbientState.getHideAmount());
            } else {
                ViewOutlineProvider.BACKGROUND.getOutline(view, outline);
            }
        }
    };

    private final Callable<Map<String, Integer>> collectVisibleLocationsCallable =
            new Callable<>() {
                @Override
                public Map<String, Integer> call() {
                    return collectVisibleNotificationLocations();
                }
            };

    private boolean mPulsing;
    private boolean mScrollable;
    private View mForcedScroll;
    private boolean mIsInsetAnimationRunning;

    private final WindowInsetsAnimation.Callback mInsetsCallback =
            new WindowInsetsAnimation.Callback(
                    WindowInsetsAnimation.Callback.DISPATCH_MODE_CONTINUE_ON_SUBTREE) {

                @Override
                public void onPrepare(WindowInsetsAnimation animation) {
                    mIsInsetAnimationRunning = true;
                }

                @Override
                public WindowInsets onProgress(WindowInsets windowInsets,
                        List<WindowInsetsAnimation> list) {
                    updateImeInset(windowInsets);
                    return windowInsets;
                }

                @Override
                public void onEnd(WindowInsetsAnimation animation) {
                    mIsInsetAnimationRunning = false;
                }
            };

    /**
     * @see #setHideAmount(float, float)
     */
    private float mInterpolatedHideAmount = 0f;

    /**
     * @see #setHideAmount(float, float)
     */
    private float mLinearHideAmount = 0f;

    /**
     * How fast the background scales in the X direction as a factor of the Y expansion.
     */
    private float mBackgroundXFactor = 1f;

    /**
     * Indicates QS are full screen and pushing notifications out of the screen.
     * It's different from QS just being expanded as in split shade QS can be expanded and
     * still don't take full screen nor influence notifications.
     */
    private boolean mQsFullScreen;
    private boolean mForwardScrollable;
    private boolean mBackwardScrollable;
    private NotificationShelf mShelf;
    /**
     * Limits the number of visible notifications. The remaining are collapsed in the notification
     * shelf. -1 when there is no limit.
     */
    private int mMaxDisplayedNotifications = -1;
    private float mKeyguardBottomPadding = -1;
    @VisibleForTesting
    int mStatusBarHeight;
    private int mMinInteractionHeight;
    private final Rect mClipRect = new Rect();
    private boolean mIsClipped;
    private Rect mRequestedClipBounds;
    private boolean mInHeadsUpPinnedMode;
    @VisibleForTesting
    boolean mHeadsUpAnimatingAway;
    private Consumer<Boolean> mHeadsUpAnimatingAwayListener;
    private int mStatusBarState;
    private boolean mHeadsUpGoingAwayAnimationsAllowed = true;
    private final Runnable mReflingAndAnimateScroll = this::animateScroll;
    private int mCornerRadius;
    private int mMinimumPaddings;
    private int mQsTilePadding;
    private boolean mSkinnyNotifsInLandscape;
    private int mSidePaddings;
    private final Rect mBackgroundAnimationRect = new Rect();
    private final ArrayList<BiConsumer<Float, Float>> mExpandedHeightListeners = new ArrayList<>();
    private int mHeadsUpInset;

    /**
     * The position of the scroll boundary relative to this view. This is where the notifications
     * stop scrolling and will start to clip instead.
     */
    private int mQsScrollBoundaryPosition;
    private HeadsUpAppearanceController mHeadsUpAppearanceController;
    private final Rect mTmpRect = new Rect();
    private ClearAllListener mClearAllListener;
    private ClearAllAnimationListener mClearAllAnimationListener;
    private Runnable mClearAllFinishedWhilePanelExpandedRunnable;
    private Consumer<Boolean> mOnStackYChanged;

    private Interpolator mHideXInterpolator = Interpolators.FAST_OUT_SLOW_IN;

    private final NotificationSectionsManager mSectionsManager;
    private float mLastSentAppear;
    private float mLastSentExpandedHeight;
    private boolean mWillExpand;
    private int mGapHeight;

    /**
     * The extra inset during the full shade transition
     */
    private float mExtraTopInsetForFullShadeTransition;

    private int mWaterfallTopInset;
    private NotificationStackScrollLayoutController mController;

    /**
     * The clip path used to clip the view in a rounded way.
     */
    private final Path mRoundedClipPath = new Path();

    /** The clip path defining where we are NOT allowed to draw. */
    private final Path mNegativeRoundedClipPath = new Path();

    /** RenderNode to blur notifications which will be reused (redrawn) whenever NSSL is drawn. */
    private final RenderNode mBlurNode = new RenderNode("BlurNode");

    /** Radius of the blur effect applied to the content of the NSSL. */
    private float mBlurRadius = 0f;
    @Nullable private RenderEffect mBlurEffect = null;

    /**
     * The clip Path used to clip the launching notification. This may be different
     * from the normal path, as the views launch animation could start clipped.
     */
    private final Path mLaunchedNotificationClipPath = new Path();

    /** Should we use rounded rect clipping right now */
    private boolean mShouldUseRoundedRectClipping = false;

    /** Should we set an out path for the drawing canvas */
    private boolean mShouldUseNegativeRoundedRectClipping = false;

    private int mRoundedRectClippingLeft;
    private int mRoundedRectClippingTop;
    private int mRoundedRectClippingBottom;
    private int mRoundedRectClippingRight;
    private int mRoundedRectClippingYTranslation;
    private final float[] mRoundedClipCornerRadii = new float[8];

    /**
     * Whether stackY should be animated in case the view is getting shorter than the scroll
     * position and this scrolling will lead to the top scroll inset getting smaller.
     */
    private boolean mAnimateStackYForContentHeightChange = false;

    /**
     * Are we launching a notification right now
     */
    private boolean mLaunchingNotification;

    /**
     * Does the launching notification need to be clipped
     */
    private boolean mLaunchingNotificationNeedsToBeClipped;

    /**
     * The current launch animation params when launching a notification
     */
    private LaunchAnimationParameters mLaunchAnimationParams;

    /**
     * Corner radii of the launched notification if it's clipped
     */
    private final float[] mLaunchedNotificationRadii = new float[8];

    /**
     * The notification that is being launched currently.
     */
    private ExpandableNotificationRow mExpandingNotificationRow;

    /**
     * Do notifications dismiss with normal transitioning
     */
    private boolean mDismissUsingRowTranslationX = true;
    private ExpandableNotificationRow mTopHeadsUpRow;
    private NotificationStackScrollLayoutController.TouchHandler mTouchHandler;
    private boolean mShouldUseSplitNotificationShade;
    private boolean mShouldSkipTopPaddingAnimationAfterFold = false;
    @Nullable private SplitShadeStateController mSplitShadeStateController = null;
    private boolean mIsSmallLandscapeLockscreenEnabled = false;

    /** Suppress the stackEndHeight updates. */
    private boolean mSuppressHeightUpdates;
    private boolean mIsOnLockscreen;

    /** Pass splitShadeStateController to view and update split shade */
    public void passSplitShadeStateController(SplitShadeStateController splitShadeStateController) {
        mSplitShadeStateController = splitShadeStateController;
        updateSplitNotificationShade();
    }
    private final FeatureFlags mFeatureFlags;

    private final ExpandableView.OnHeightChangedListener mOnChildHeightChangedListener =
            new ExpandableView.OnHeightChangedListener() {
                @Override
                public void onHeightChanged(ExpandableView view, boolean needsAnimation) {
                    onChildHeightChanged(view, needsAnimation);
                }

                @Override
                public void onReset(ExpandableView view) {
                    onChildHeightReset(view);
                }
            };

    private final NotificationEntry.OnSensitivityChangedListener
            mOnChildSensitivityChangedListener =
            new NotificationEntry.OnSensitivityChangedListener() {
                @Override
                public void onSensitivityChanged(NotificationEntry entry) {
                    if (mAnimationsEnabled) {
                        mHideSensitiveNeedsAnimation = true;
                        requestChildrenUpdate();
                    }
                }
            };

    private Consumer<Integer> mScrollListener;
    private final ScrollAdapter mScrollAdapter = new ScrollAdapter() {
        @Override
        public boolean isScrolledToTop() {
            if (SceneContainerFlag.isEnabled()) {
                return mScrollViewFields.scrollState.isScrolledToTop();
            } else {
                return getOwnScrollY() == 0;
            }
        }

        @Override
        public boolean isScrolledToBottom() {
            return getOwnScrollY() >= getScrollRange();
        }

        @Override
        public View getHostView() {
            return NotificationStackScrollLayout.this;
        }
    };

    public NotificationStackScrollLayout(Context context, AttributeSet attrs) {
        super(context, attrs, 0, 0);
        Resources res = getResources();
        mFeatureFlags = Dependency.get(FeatureFlags.class);
        mIsSmallLandscapeLockscreenEnabled = mFeatureFlags.isEnabled(
                Flags.LOCKSCREEN_ENABLE_LANDSCAPE);
        mDebugLines = mFeatureFlags.isEnabled(Flags.NSSL_DEBUG_LINES);
        mDebugRemoveAnimation = mFeatureFlags.isEnabled(Flags.NSSL_DEBUG_REMOVE_ANIMATION);
        mSectionsManager = Dependency.get(NotificationSectionsManager.class);
        mSectionsManager.initialize(this);
        mSections = mSectionsManager.createSectionsForBuckets();

        mAmbientState = Dependency.get(AmbientState.class);
        int minHeight = res.getDimensionPixelSize(R.dimen.notification_min_height);
        int maxHeight = res.getDimensionPixelSize(R.dimen.notification_max_height);
        mSplitShadeMinContentHeight = res.getDimensionPixelSize(
                R.dimen.nssl_split_shade_min_content_height);
        mExpandHelper = new ExpandHelper(getContext(), mExpandHelperCallback,
                minHeight, maxHeight);
        mExpandHelper.setEventSource(this);
        mExpandHelper.setScrollAdapter(mScrollAdapter);

        mHeadsUpAnimator = new HeadsUpAnimator(context, /* systemBarUtilsProxy= */ null);
        mStackScrollAlgorithm =  new StackScrollAlgorithm(context, this, mHeadsUpAnimator);
        mStateAnimator = new StackStateAnimator(context, this, mHeadsUpAnimator);
        setOutlineProvider(mOutlineProvider);

        // We could set this whenever we 'requestChildUpdate' much like the viewTreeObserver, but
        // that adds a bunch of complexity, and drawing nothing isn't *that* expensive.
        boolean willDraw = SceneContainerFlag.isEnabled() || mDebugLines;
        setWillNotDraw(!willDraw);
        if (mDebugLines) {
            mDebugPaint = new Paint();
            mDebugPaint.setColor(0xffff0000);
            mDebugPaint.setStrokeWidth(2);
            mDebugPaint.setStyle(Paint.Style.STROKE);
            mDebugPaint.setTextSize(25f);
        }
        mGroupMembershipManager = Dependency.get(GroupMembershipManager.class);
        mGroupExpansionManager = Dependency.get(GroupExpansionManager.class);
        setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
        if (!SceneContainerFlag.isEnabled()) {
            setWindowInsetsAnimationCallback(mInsetsCallback);
        }
    }

    /**
     * Set the overexpansion of the panel to be applied to the view.
     */
    void setOverExpansion(float margin) {
        mAmbientState.setOverExpansion(margin);
        if (!SceneContainerFlag.isEnabled()) {
            setRoundingClippingYTranslation(mShouldUseSplitNotificationShade ? (int) margin : 0);
        }
        updateStackPosition();
        requestChildrenUpdate();
    }

    /**
     * Sets whether keyguard bypass is enabled. If true, this layout will be rendered in bypass
     * mode when it is on the keyguard.
     */
    public void setKeyguardBypassEnabled(boolean isEnabled) {
        mKeyguardBypassEnabled = isEnabled;
    }

    /**
     * @return the height at which we will wake up when pulsing
     */
    public float getWakeUpHeight() {
        ExpandableView firstChild = getFirstChildWithBackground();
        if (firstChild != null) {
            if (mKeyguardBypassEnabled) {
                return firstChild.getHeadsUpHeightWithoutHeader();
            } else {
                return firstChild.getCollapsedHeight();
            }
        }
        return 0f;
    }

    protected void setLogger(NotificationStackScrollLogger logger) {
        mLogger = logger;
    }

    public float getNotificationSquishinessFraction() {
        SceneContainerFlag.assertInLegacyMode();
        return mStackScrollAlgorithm.getNotificationSquishinessFraction(mAmbientState);
    }

    void reinflateViews() {
        mSectionsManager.reinflateViews();
    }

    /**
     * Requests to scroll to the RemoteInput view of the given row.
     * @param row currently holding the active RemoteInput, or null if the RemoteInput is inactive.
     */
    void requestScrollToRemoteInput(@Nullable ExpandableNotificationRow row) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) {
            return;
        }
        // When SceneContainer is enabled, the NSSL doesn't handle its own scrolling.
        // Instead, it calculates the required scroll amount and emits it as a "synthetic scroll"
        // event. The Compose-based UI (`NotificationScrollingStack.kt`) consumes this event
        // and performs the actual scroll, synchronizing it with the IME animation.
        Float bottom = (row != null) ? getRemoteInputViewBottom(row) : null;
        mScrollViewFields.sendRemoteInputRowBottomBound(bottom);
    }

    /**
     * Calculates the Y position of the bottom of the remoteInput view, relative to the NSSL.
     *
     * @param row with an active remoteInput
     * @return The bottom Y position of the view relative to this layout.
     */
    private float getRemoteInputViewBottom(ExpandableNotificationRow row) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return 0f;
        // Calculate the base top position
        float topPosition = row.getTranslationY()
                // For nested notifications, add the parent's translation.
                + (isChildInGroup(row) ? row.getNotificationParent().getTranslationY() : 0f);
        int height = row.getActualHeight();
        float remoteInputOffset = row.getRemoteInputActionsContainerExpandedOffset();
        float contentMargin = getResources().getDimensionPixelSize(
                com.android.internal.R.dimen.notification_content_margin);
        return topPosition + height + remoteInputOffset + contentMargin;
    }

    void updateBgColor() {
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child instanceof ActivatableNotificationView activatableView) {
                activatableView.updateBackgroundColors();
            }
        }
    }

    private void onJustBeforeDraw() {
        if (SceneContainerFlag.isEnabled()) {
            if (mChildrenUpdateRequested) {
                updateChildren();
                mChildrenUpdateRequested = false;
            }
        }
    }

    protected void onDraw(Canvas canvas) {
        onJustBeforeDraw();

        if (mDebugLines) {
            onDrawDebug(canvas);
        }
    }

    private void logHunSkippedForUnexpectedState(ExpandableNotificationRow enr,
                                                 boolean expected, boolean actual) {
        if (mLogger == null) return;
        mLogger.hunSkippedForUnexpectedState(enr.getLoggingKey(), expected, actual);
    }

    private void logHunAnimationSkipped(ExpandableNotificationRow enr, String reason) {
        if (mLogger == null) return;
        mLogger.hunAnimationSkipped(enr.getLoggingKey(), reason);
    }

    private void logHunAnimationEventAdded(ExpandableNotificationRow enr, int type) {
        if (mLogger == null) return;
        mLogger.hunAnimationEventAdded(enr.getLoggingKey(), type);
    }

    private void onDrawDebug(Canvas canvas) {
        if (mDebugTextUsedYPositions == null) {
            mDebugTextUsedYPositions = new HashSet<>();
        } else {
            mDebugTextUsedYPositions.clear();
        }

        mDebugPaint.setColor(Color.DKGRAY);
        canvas.drawPath(mRoundedClipPath, mDebugPaint);

        int y = 0;
        drawDebugInfo(canvas, y, Color.RED, /* label= */ "y = " + y);

        if (SceneContainerFlag.isEnabled()) {
            y = (int) mAmbientState.getStackTop();
            drawDebugInfo(canvas, y, Color.RED, /* label= */ "getStackTop() = " + y);

            y = (int) mAmbientState.getStackCutoff();
            drawDebugInfo(canvas, y, Color.MAGENTA, /* label= */ "getStackCutoff() = " + y);

            y = (int) mAmbientState.getHeadsUpTop();
            drawDebugInfo(canvas, y, Color.GREEN, /* label= */ "getHeadsUpTop() = " + y);

            y = (int) (mAmbientState.getStackTop() + mScrollViewFields.intrinsicStackHeight);
            drawDebugInfo(canvas, y, Color.BLUE,
                    /* label= */ "getStackTop() + getIntrinsicStackHeight() = " + y);

            drawDebugInfo(canvas, mAmbientState.getDrawBounds(), Color.YELLOW, "drawBounds");

            return; // the rest of the fields are not important in Flexiglass
        }

        y = mAmbientState.getTopPadding();
        drawDebugInfo(canvas, y, Color.RED, /* label= */ "getTopPadding() = " + y);

        y = getLayoutHeight();
        drawDebugInfo(canvas, y, Color.YELLOW, /* label= */ "getLayoutHeight() = " + y);

        y = mMaxLayoutHeight;
        drawDebugInfo(canvas, y, Color.MAGENTA, /* label= */ "mMaxLayoutHeight = " + y);

        // The space between mTopPadding and mKeyguardBottomPadding determines the available space
        // for notifications on keyguard.
        if (mKeyguardBottomPadding >= 0) {
            y = getHeight() - (int) mKeyguardBottomPadding;
            drawDebugInfo(canvas, y, Color.RED,
                    /* label= */ "getHeight() - mKeyguardBottomPadding = " + y);
        }

        y = getHeight() - getEmptyBottomMarginInternal();
        drawDebugInfo(canvas, y, Color.GREEN,
                /* label= */ "getHeight() - getEmptyBottomMargin() = " + y);

        y = (int) (mAmbientState.getStackY());
        drawDebugInfo(canvas, y, Color.CYAN, /* label= */ "mAmbientState.getStackY() = " + y);

        y = (int) (mAmbientState.getStackY() + mAmbientState.getInterpolatedStackHeight());
        drawDebugInfo(canvas, y, Color.LTGRAY,
                /* label= */ "mAmbientState.getStackY() + mAmbientState.getStackHeight() = " + y);

        y = (int) (mAmbientState.getStackY() + getIntrinsicContentHeight());
        drawDebugInfo(canvas, y, Color.YELLOW,
                /* label= */ "mAmbientState.getStackY() + mIntrinsicContentHeight = " + y);

        y = getContentHeight();
        drawDebugInfo(canvas, y, Color.MAGENTA,
                /* label= */ "mContentHeight = " + y);

        y = mRoundedRectClippingBottom;
        drawDebugInfo(canvas, y, Color.DKGRAY,
                /* label= */ "mRoundedRectClippingBottom) = " + y);
    }

    private void drawDebugInfo(Canvas canvas, int y, int color, String label) {
        mDebugPaint.setColor(color);
        canvas.drawLine(/* startX= */ 0, /* startY= */ y, /* stopX= */ getWidth(), /* stopY= */ y,
                mDebugPaint);
        canvas.drawText(label, /* x= */ 0, /* y= */ computeDebugYTextPosition(y), mDebugPaint);
    }

    private void drawDebugInfo(Canvas canvas, RectF rect, int color, String label) {
        mDebugPaint.setColor(color);
        canvas.drawRect(rect, mDebugPaint);
        canvas.drawText(label, /* x= */ 0, /* y= */ computeDebugYTextPosition((int) rect.top),
                mDebugPaint);
    }

    private int computeDebugYTextPosition(int lineY) {
        int textY = lineY;
        while (mDebugTextUsedYPositions.contains(textY)) {
            textY = (int) (textY + mDebugPaint.getTextSize());
        }
        mDebugTextUsedYPositions.add(textY);
        return textY;
    }

    private void reinitView() {
        initView(getContext(), mSwipeHelper, mNotificationStackSizeCalculator);
    }

    void initView(Context context, NotificationSwipeHelper swipeHelper,
                  NotificationStackSizeCalculator notificationStackSizeCalculator) {
        mScroller = !SceneContainerFlag.isEnabled()
                ? OverScrollerWrapper.wrap(new OverScroller(getContext()))
                : new NoOpOverScroller();
        mSwipeHelper = swipeHelper;
        mNotificationStackSizeCalculator = notificationStackSizeCalculator;

        setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
        setClipChildren(false);
        final ViewConfiguration configuration = ViewConfiguration.get(context);
        mTouchSlop = configuration.getScaledTouchSlop();
        mSlopMultiplier = configuration.getScaledAmbiguousGestureMultiplier();
        mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
        mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
        mOverflingDistance = configuration.getScaledOverflingDistance();

        Resources res = context.getResources();
        mSwipeHelper.updateResourceProperties(res);
        final boolean isSmallScreenLandscape = res.getBoolean(R.bool.is_small_screen_landscape);
        boolean useSmallLandscapeLockscreenResources = mIsSmallLandscapeLockscreenEnabled
                && isSmallScreenLandscape;
        // TODO (b/293252410) remove condition here when flag is launched
        //  Instead update the config_skinnyNotifsInLandscape to be false whenever
        //  is_small_screen_landscape is true. Then, only use the config_skinnyNotifsInLandscape.
        final boolean configSkinnyNotifsInLandscape = res.getBoolean(
                R.bool.config_skinnyNotifsInLandscape);
        if (useSmallLandscapeLockscreenResources) {
            mSkinnyNotifsInLandscape = false;
        } else {
            mSkinnyNotifsInLandscape = configSkinnyNotifsInLandscape;
        }

        mLastInitViewDumpString =
                "mIsSmallLandscapeLockscreenEnabled=" + mIsSmallLandscapeLockscreenEnabled
                        + " isSmallScreenLandscape=" + isSmallScreenLandscape
                        + " useSmallLandscapeLockscreenResources="
                        + useSmallLandscapeLockscreenResources
                        + " skinnyNotifsInLandscape=" + configSkinnyNotifsInLandscape
                        + " mSkinnyNotifsInLandscape=" + mSkinnyNotifsInLandscape;
        mLastInitViewElapsedRealtime = SystemClock.elapsedRealtime();

        mGapHeight = res.getDimensionPixelSize(R.dimen.notification_section_divider_height);
        mStackScrollAlgorithm.initView(context);
        mStateAnimator.initView(context);
        mAmbientState.reload(context);
        mPaddingBetweenElements = Math.max(1,
                res.getDimensionPixelSize(R.dimen.notification_divider_height));
        mMinimumSpacingBetweenChildren = Math.max(1,
                res.getDimensionPixelSize(R.dimen.notification_minimum_spacing_between_children));
        mMinTopOverScrollToEscape = res.getDimensionPixelSize(
                R.dimen.min_top_overscroll_to_qs);
        mStatusBarHeight = SystemBarUtils.getStatusBarHeight(mContext);
        mBottomPadding = res.getDimensionPixelSize(R.dimen.notification_panel_padding_bottom);
        mMinimumPaddings = res.getDimensionPixelSize(R.dimen.notification_side_paddings);
        mQsTilePadding = res.getDimensionPixelOffset(R.dimen.qs_tile_margin_horizontal);
        mSidePaddings = mMinimumPaddings;  // Updated in onMeasure by updateSidePadding()
        mMinInteractionHeight = res.getDimensionPixelSize(
                R.dimen.notification_min_interaction_height);
        mCornerRadius = res.getDimensionPixelSize(R.dimen.notification_corner_radius);
        mHeadsUpInset = mStatusBarHeight + res.getDimensionPixelSize(
                R.dimen.heads_up_status_bar_padding);
        mQsScrollBoundaryPosition = SystemBarUtils.getQuickQsOffsetHeight(mContext);
    }

    void updateSidePadding(int viewWidth) {
        final int orientation = getResources().getConfiguration().orientation;

        mLastUpdateSidePaddingDumpString = "viewWidth=" + viewWidth
                + " skinnyNotifsInLandscape=" + mSkinnyNotifsInLandscape
                + " orientation=" + orientation;
        mLastUpdateSidePaddingElapsedRealtime = SystemClock.elapsedRealtime();

        if (viewWidth == 0) {
            Log.e(TAG, "updateSidePadding: viewWidth is zero");
            mSidePaddings = mMinimumPaddings;
            return;
        }

        if (orientation == Configuration.ORIENTATION_PORTRAIT) {
            mSidePaddings = mMinimumPaddings;
            return;
        }

        if (mShouldUseSplitNotificationShade) {
            if (mSkinnyNotifsInLandscape) {
                Log.e(TAG, "updateSidePadding: mSkinnyNotifsInLandscape has betrayed us!");
            }
            mSidePaddings = mMinimumPaddings;
            return;
        }

        final int innerWidth = viewWidth - mMinimumPaddings * 2;
        final int qsTileWidth = (innerWidth - mQsTilePadding * 3) / 4;
        mSidePaddings = mMinimumPaddings + qsTileWidth + mQsTilePadding;
    }

    void updateCornerRadius() {
        int newRadius = getResources().getDimensionPixelSize(R.dimen.notification_corner_radius);
        if (mCornerRadius != newRadius) {
            mCornerRadius = newRadius;
            invalidate();
        }
    }

    private void notifyHeightChangeListener(ExpandableView view) {
        notifyHeightChangeListener(view, false /* needsAnimation */);
    }

    private void notifyHeightChangeListener(ExpandableView view, boolean needsAnimation) {
        if (mOnHeightChangedListener != null) {
            mOnHeightChangedListener.onHeightChanged(view, needsAnimation);
        }

        if (mOnHeightChangedRunnable != null) {
            mOnHeightChangedRunnable.run();
        }
    }

    public boolean isPulseExpanding() {
        return mAmbientState.isPulseExpanding();
    }

    public int getSpeedBumpIndex() {
        if (mSpeedBumpIndexDirty) {
            mSpeedBumpIndexDirty = false;
            int speedBumpIndex = 0;
            int currentIndex = 0;
            final int n = getChildCount();
            for (int i = 0; i < n; i++) {
                View view = getChildAt(i);
                if (view.getVisibility() == View.GONE
                        || !(view instanceof ExpandableNotificationRow row)) {
                    continue;
                }
                int bucket = NotificationBundleUi.isEnabled()
                        ? row.getEntryAdapter().getSectionBucket()
                        : row.getEntryLegacy().getBucket();
                boolean isAmbient = NotificationBundleUi.isEnabled()
                        ? row.getEntryAdapter().isAmbient()
                        : row.getEntryLegacy().isAmbient();
                currentIndex++;
                boolean beforeSpeedBump;
                if (mHighPriorityBeforeSpeedBump) {
                    beforeSpeedBump = bucket < BUCKET_SILENT;
                } else {
                    beforeSpeedBump = !isAmbient;
                }
                if (beforeSpeedBump) {
                    speedBumpIndex = currentIndex;
                }
            }

            mSpeedBumpIndex = speedBumpIndex;
        }
        return mSpeedBumpIndex;
    }

    private boolean mSuppressChildrenMeasureAndLayout = false;

    /**
     * Similar to {@link ViewGroup#suppressLayout} but still performs layout of
     * the container itself and suppresses only measure and layout calls to children.
     */
    public void suppressChildrenMeasureAndLayout(boolean suppress) {
        mSuppressChildrenMeasureAndLayout = suppress;

        if (!suppress) {
            requestLayout();
        }
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        Trace.beginSection("NotificationStackScrollLayout#onMeasure");
        if (SPEW) {
            Log.d(TAG, "onMeasure("
                    + "widthMeasureSpec=" + MeasureSpec.toString(widthMeasureSpec) + ", "
                    + "heightMeasureSpec=" + MeasureSpec.toString(heightMeasureSpec) + ")");
        }
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        int width = MeasureSpec.getSize(widthMeasureSpec);
        updateSidePadding(width);

        if (mSuppressChildrenMeasureAndLayout) {
            Trace.endSection();
            return;
        }

        int childWidthSpec = MeasureSpec.makeMeasureSpec(width - mSidePaddings * 2,
                MeasureSpec.getMode(widthMeasureSpec));
        // Don't constrain the height of the children so we know how big they'd like to be
        int childHeightSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightMeasureSpec),
                MeasureSpec.UNSPECIFIED);

        // We need to measure all children even the GONE ones, such that the heights are calculated
        // correctly as they are used to calculate how many we can fit on the screen.
        final int size = getChildCount();
        for (int i = 0; i < size; i++) {
            measureChild(getChildAt(i), childWidthSpec, childHeightSpec);
        }
        if (SceneContainerFlag.isEnabled()) {
            setMaxLayoutHeight(getMeasuredHeight());
            updateContentHeight();
        }
        Trace.endSection();
    }

    @Override
    public void requestLayout() {
        Trace.instant(TRACE_TAG_APP, "NotificationStackScrollLayout#requestLayout");
        super.requestLayout();
    }

    private void notifyStackHeightChangedListeners() {
        for (Runnable listener : mStackHeightChangedListeners) {
            listener.run();
        }
    }

    @Override
    public void addStackHeightChangedListener(@NonNull Runnable runnable) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mStackHeightChangedListeners.addIfAbsent(runnable);
    }

    @Override
    public void removeStackHeightChangedListener(@NonNull Runnable runnable) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mStackHeightChangedListeners.remove(runnable);
    }

    private void notifyHeadsUpHeightChangedForView(View view) {
        if (mTopHeadsUpRow == view) {
            notifyHeadsUpHeightChangedListeners();
        }
    }

    private void notifyHeadsUpHeightChangedListeners() {
        for (Runnable listener : mHeadsUpHeightChangedListeners) {
            listener.run();
        }
    }

    @Override
    public void addHeadsUpHeightChangedListener(@NonNull Runnable runnable) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mHeadsUpHeightChangedListeners.addIfAbsent(runnable);
    }

    @Override
    public void removeHeadsUpHeightChangedListener(@NonNull Runnable runnable) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mHeadsUpHeightChangedListeners.remove(runnable);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        if (!mSuppressChildrenMeasureAndLayout) {
            // we layout all our children centered on the top
            float centerX = getWidth() / 2.0f;
            for (int i = 0; i < getChildCount(); i++) {
                View child = getChildAt(i);
                // We need to layout all children even the GONE ones, such that the heights are
                // calculated correctly as they are used to calculate how many we can fit on
                // the screen
                float width = child.getMeasuredWidth();
                float height = child.getMeasuredHeight();
                child.layout((int) (centerX - width / 2.0f),
                        0,
                        (int) (centerX + width / 2.0f),
                        (int) height);
            }
        }
        if (!SceneContainerFlag.isEnabled()) {
            setMaxLayoutHeight(getHeight());
            updateContentHeight();
        }
        clampScrollPosition();
        requestChildrenUpdate();
        updateFirstAndLastBackgroundViews();
        updateAlgorithmLayoutMinHeight();
        updateOwnTranslationZ();

        if (!SceneContainerFlag.isEnabled()) {
            // Give The Algorithm information regarding the QS height so it can layout notifications
            // properly. Needed for some devices that grows notifications down-to-top
            int height;
            if (QSComposeFragment.isEnabled()) {
                if (mQSHeaderBoundsProvider != null) {
                    height = mQSHeaderBoundsProvider.getHeightProvider().invoke();
                } else {
                    height = 0;
                }
            } else {
                if (mQsHeader != null) {
                    height = mQsHeader.getHeight();
                } else {
                    height = 0;
                }
            }
            mStackScrollAlgorithm.updateQSFrameTop(height);
        }

        // Once the layout has finished, we don't need to animate any scrolling clampings anymore.
        mAnimateStackYForContentHeightChange = false;
    }

    private void requestAnimationOnViewResize(ExpandableNotificationRow row) {
        if (mAnimationsEnabled && (mIsExpanded || row != null && row.isPinned())) {
            mNeedViewResizeAnimation = true;
            mNeedsAnimation = true;
        }
    }

    @NonNull
    @Override
    public View asView() {
        return this;
    }

    private final ObservableState<Integer> mObservableLeft = new DownstreamObservableState<>() {
        @NonNull
        @Override
        protected DisposableHandle subscribeUpstream(
                @NonNull Function1<? super Integer, Unit> onUpstreamChange) {
            OnLayoutChangeListener listener =
                    (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) ->
                            onUpstreamChange.invoke(getLeft());
            addOnLayoutChangeListener(listener);
            return () -> removeOnLayoutChangeListener(listener);
        }

        @Override
        protected Integer pollValue() {
            return getLeft();
        }
    };

    @NonNull
    @Override
    public ObservableState<Integer> getObservableLeft() {
        return mObservableLeft;
    }

    @Override
    public void setMaxAlpha(float alpha) {
        mController.setMaxAlphaFromView(alpha);
    }

    @Override
    public void setOccluded(boolean isOccluded) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) {
            return;
        }
        this.setVisibility(isOccluded ? View.INVISIBLE : View.VISIBLE);
    }

    @Override
    public void setInteractive(boolean interactive) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()
                || mScrollViewFields.interactive == interactive) {
            return;
        }

        // Clear the outBoundsEventIds because otherwise, the ongoing event ids won't be cleared
        mOutBoundsEventId = -1;

        mScrollViewFields.interactive = interactive;
        setImportantForAccessibility(
                mScrollViewFields.interactive ? View.IMPORTANT_FOR_ACCESSIBILITY_YES :
                        View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
        );


        setDescendantFocusability(
                mScrollViewFields.interactive ? FOCUS_AFTER_DESCENDANTS :
                        FOCUS_BLOCK_DESCENDANTS
        );
        // We don't want the NSSL to be focusable unless both mScrollViewFields.interactive and
        // mScrollable are true
        setFocusable(mScrollViewFields.interactive && mScrollable);
    }

    @Override
    public void setScrollState(@NonNull ShadeScrollState scrollState) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) {
            return;
        }

        boolean forwardScrollable =
                scrollState.getScrollPosition() < scrollState.getMaxScrollPosition();
        boolean backwardScrollable = scrollState.getScrollPosition() > 0;
        mScrollable = forwardScrollable || backwardScrollable;
        mForwardScrollable = forwardScrollable;
        mBackwardScrollable = backwardScrollable;

        boolean scrollPositionChanged = mScrollViewFields.scrollState.getScrollPosition()
                != scrollState.getScrollPosition();
        mScrollViewFields.scrollState = scrollState;

        if (scrollPositionChanged) {
            sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SCROLLED);
        }
    }

    @Override
    public void setStackTop(float stackTop) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        if (mAmbientState.getStackTop() != stackTop) {
            mAmbientState.setStackTop(stackTop);
            onTopPaddingChanged(/* animate = */ isAddOrRemoveAnimationPending());
        }
    }

    @Override
    public void updateDrawBounds(@NonNull RectF drawBounds) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        // The received drawBounds are relative to the Window, but NSSL  expects a rect relative to
        // its own position, so we need to offset it in case the NSSL has some horizontal margins.
        drawBounds.offset(-getX(), -getY());
        if (mAmbientState.getDrawBounds() != drawBounds) {
            mAmbientState.setDrawBounds(drawBounds);
            updateStackEndHeightAndStackHeight(mAmbientState.getExpansionFraction());
            requestChildrenUpdate();
        }
    }

    @Override
    public void setHeadsUpTop(float headsUpTop) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        if (mAmbientState.getHeadsUpTop() != headsUpTop) {
            mAmbientState.setHeadsUpTop(headsUpTop);
            requestChildrenUpdate();
        }
    }

    @Override
    public void setHeadsUpBottom(float headsUpBottom) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mHeadsUpAnimator.setHeadsUpAppearHeightBottom(Math.round(headsUpBottom));
    }

    @Override
    public void closeGutsOnSceneTouch() {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mController.closeControlsDueToOutsideTouch();
    }

    @Override
    public void setSyntheticScrollConsumer(@Nullable Consumer<Float> consumer) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mScrollViewFields.setSyntheticScrollConsumer(consumer);
    }

    @Override
    public void setAccessibilityScrollEventConsumer(
            @Nullable Consumer<AccessibilityScrollEvent> consumer) {
        mScrollViewFields.setAccessibilityScrollEventConsumer(consumer);
    }

    @Override
    public void setCurrentGestureExpandingNotificationConsumer(
            @Nullable Consumer<Boolean> consumer) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mScrollViewFields.setCurrentGestureExpandingNotificationConsumer(consumer);
    }

    @Override
    public void setCurrentGestureInGutsConsumer(@Nullable Consumer<Boolean> consumer) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mScrollViewFields.setCurrentGestureInGutsConsumer(consumer);
    }

    @Override
    public void setRemoteInputRowBottomBoundConsumer(@Nullable Consumer<Float> consumer) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mScrollViewFields.setRemoteInputRowBottomBoundConsumer(consumer);
    }

    /**
     * @param listener to be notified after the location of Notification children might have
     *                 changed.
     */
    public void setNotificationLocationsChangedListener(
            @Nullable OnNotificationLocationsChangedListener listener) {
        mLocationsChangedListener = listener;
    }

    private void setMaxLayoutHeight(int maxLayoutHeight) {
        mMaxLayoutHeight = maxLayoutHeight;
        updateAlgorithmHeightAndPadding();
    }

    private void updateAlgorithmHeightAndPadding() {
        mAmbientState.setLayoutHeight(getLayoutHeight());
        mAmbientState.setLayoutMaxHeight(mMaxLayoutHeight);
        updateAlgorithmLayoutMinHeight();
    }

    private void updateAlgorithmLayoutMinHeight() {
        if (!SceneContainerFlag.isEnabled()) {
            mAmbientState.setLayoutMinHeight(mQsFullScreen || isHeadsUpTransition()
                    ? getLayoutMinHeightInternal() : 0);
        }
    }

    /**
     * Updates the children views according to the stack scroll algorithm. Call this whenever
     * modifications to {@link #getOwnScrollY()} are performed to reflect it in the view layout.
     */
    private void updateChildren() {
        Trace.beginSection("NSSL#updateChildren");
        updateScrollStateForAddedChildren();
        mAmbientState.setCurrentScrollVelocity(mScroller.isFinished()
                ? 0
                : mScroller.getCurrVelocity());
        mStackScrollAlgorithm.resetViewStates(mAmbientState, getSpeedBumpIndex());
        if (!isCurrentlyAnimating() && !mNeedsAnimation) {
            applyCurrentState();
        } else {
            startAnimationToState();
        }
        avoidNotificationOverlaps();
        Trace.endSection();
    }

    private void onPreDrawDuringAnimation() {
        mShelf.updateAppearance();
        avoidNotificationOverlaps();
    }

    /**
     * Avoids overlaps between notifications by postprocessing its positions and adjusting clipping.
     * Children are sorted by the notGoneIndex calculated in the algorithm.
     * Usually the later views in the list are clipped on the top to avoid the overlap.
     * However, views that are disappearing (because they are dismissed or removed) are clipped on
     * the bottom instead to avoid the overlap.
     */
    @VisibleForTesting
    void avoidNotificationOverlaps() {
        if (!physicalNotificationMovement()) {
            return;
        }
        createSortedNotificationLists(mTmpSortedChildren, mTmpNonOverlapChildren);
        // Now lets update the overlaps for the views, ensuring that we set the values for every
        // view, otherwise they might get stuck.
        mTmpNonOverlapChildren.forEach((child) -> {
            child.setBottomOverlap(0);
            child.setTopOverlap(0);
        });

        // The NSSL can actually  be inset and notifications even rendering above it. Let's make
        // sure that we don't clip them at 0 but use some large negative number (not min
        // because overflow)
        float minimumClipPosition = Integer.MIN_VALUE >> 1;
        ExpandableView lastTransientView = null;
        float transientClippingPosition = minimumClipPosition;
        float lastGroupEnd = minimumClipPosition;
        for (int i = 0; i < mTmpSortedChildren.size(); i++) {
            ExpandableView expandableView = mTmpSortedChildren.get(i);
            float currentTop = getRelativePosition(expandableView);
            boolean isTransient = expandableView.getTransientContainer() != null;
            boolean childInGroup = expandableView.isChildInGroup();
            if (expandableView instanceof ExpandableNotificationRow) {
                if (expandableView.getTransientContainer()
                        instanceof NotificationChildrenContainer) {
                    // Children are removed from their parent so isChildInGroup doesn't work anymore
                    childInGroup = true;
                }
            }
            if (!childInGroup) {
                // if we're not a child and there was a group before, let's make sure the get
                // at least the end of that last group
                minimumClipPosition = Math.max(minimumClipPosition, lastGroupEnd);
            }
            float clipping = Math.max(minimumClipPosition - currentTop, 0);
            int clippingInt = Math.round(clipping);
            expandableView.setTopOverlap(clippingInt);
            if (lastTransientView != null) {
                // Clip the last transientView
                float transientClipping = Math.max(transientClippingPosition - currentTop, 0);
                lastTransientView.setBottomOverlap(Math.round(transientClipping));
            }
            if (expandableView instanceof ActivatableNotificationView anv) {
                if (isTransient) {
                    // TransientViews never clip the next, they are clipped themselves
                    lastTransientView = expandableView;
                    // When transient, we will be clipped ourselves. For that position we need to
                    // take the difference from the actual height and not from the visible position
                    float clipPosition = currentTop + expandableView.getActualHeight()
                            + mMinimumSpacingBetweenChildren;
                    transientClippingPosition = Math.max(minimumClipPosition, clipPosition);
                } else {
                    int backgroundBottom = anv.getBackgroundBottom();
                    float clipPosition = currentTop + backgroundBottom
                            + mMinimumSpacingBetweenChildren;
                    float positionAfterView = Math.max(minimumClipPosition, clipPosition);
                    lastTransientView = null;
                    if (!expandableView.isSummaryWithChildren()
                            || !expandableView.isGroupExpanded()) {
                        // new clipping position is at our end
                        minimumClipPosition = positionAfterView;
                    } else {
                        // new clip position is at our start
                        float headerStart =
                                currentTop + ((ExpandableNotificationRow) expandableView)
                                        .getChildRenderingStartPosition();
                        minimumClipPosition = Math.max(minimumClipPosition, headerStart);
                        lastGroupEnd = positionAfterView;
                    }
                }
            }
            if (!isTransient) {
                // only transient views are clipped on the bottom. Let's reset it otherwise!
                expandableView.setBottomOverlap(0);
            }
        }
        if (lastTransientView != null) {
            lastTransientView.setBottomOverlap(0);
        }
        mTmpSortedChildren.clear();
        mTmpNonOverlapChildren.clear();
    }

    /**
     * Calculates the relative position of the view compared to ourselves. This is similar to
     * getLocationOnScreen[1] however this also works for transientViews.
     */
    private float getRelativePosition(ExpandableView expandableView) {
        mTempFloat2[0] = 0f;
        mTempFloat2[1] = 0f;
        if (!expandableView.hasIdentityMatrix()) {
            expandableView.getMatrix().mapPoints(mTempFloat2);
        }

        mTempFloat2[1] += expandableView.getTop();

        ViewParent viewParent = expandableView.getParent();
        while (viewParent instanceof View && viewParent != this) {
            final View view = (View) viewParent;

            mTempFloat2[1] -= view.getScrollY();

            if (!view.hasIdentityMatrix()) {
                view.getMatrix().mapPoints(mTempFloat2);
            }

            mTempFloat2[1] += view.getTop();
            viewParent = view.getParent();
            if (viewParent == this) {
                return mTempFloat2[1];
            }
        }
        return mTempFloat2[1];
    }

    /**
     * Fill a list of sorted children that should avoid overlap and a list who's overlap should
     * be cleared / no overlaps should be handled.
     * @param overlappingList the list where this result is fed into
     * @param nonOverlappingList the list where we put views to reset
     */
    @VisibleForTesting
    void createSortedNotificationLists(
            List<ExpandableView> overlappingList,
            List<ExpandableView> nonOverlappingList) {
        for (int i = 0; i < getChildCount(); i++) {
            ExpandableView child = getChildAtIndex(i);
            mTmpStack.add(child);
        }
        for (int i = 0; i < getTransientViewCount(); i++) {
            ExpandableView child = (ExpandableView) getTransientView(i);
            mTmpStack.add(child);
        }
        while (!mTmpStack.isEmpty()) {
            ExpandableView child = mTmpStack.pop();
            if (child instanceof NotificationShelf) {
                // we never overlap / clip the shelf
                continue;
            }
            boolean canClip = !child.isBackgroundOpaque();
            if (child instanceof ExpandableNotificationRow row) {
                if (canClip && row.isChildInGroup()) {
                    canClip = canClipChildRow(row);
                }
                // handle the notGoneIndex for the children as well
                List<ExpandableNotificationRow> children = row.getAttachedChildren();
                if (row.isSummaryWithChildren() && children != null) {
                    mTmpStack.addAll(children);
                    // Add all the transient Views
                    ViewGroup childrenContainer = row.getChildrenContainer();
                    for (int j = 0; j < childrenContainer.getTransientViewCount(); j++) {
                        ExpandableView childRow =
                                (ExpandableView) childrenContainer.getTransientView(j);
                        mTmpStack.add(childRow);
                    }
                }
            }
            if (canClip && child.getVisibility() == VISIBLE && child.getAlpha() != 0.0f) {
                // This view should be clipped!
                overlappingList.add(child);
            } else {
                nonOverlappingList.add(child);
            }
        }
        Collections.sort(overlappingList, mNotGoneIndexComparator);
    }

    private boolean canClipChildRow(ExpandableNotificationRow row) {
        ExpandableNotificationRow notifParent = row.getNotificationParent();
        if (NotificationBundleUi.isEnabled()) {
            return notifParent.isGroupExpanded()
                    && !notifParent.isGroupExpansionChanging()
                    && (!notifParent.isChildInGroup() || canClipChildRow(notifParent));
        } else {
            return notifParent.isGroupExpanded()
                    && !notifParent.isGroupExpansionChanging();
        }
    }

    private void updateScrollStateForAddedChildren() {
        if (mChildrenToAddAnimated.isEmpty()) {
            return;
        }
        for (int i = 0; i < getChildCount(); i++) {
            ExpandableView child = getChildAtIndex(i);
            if (mChildrenToAddAnimated.contains(child)) {
                final int startingPosition = getPositionInLinearLayout(child);
                final int childHeight = getIntrinsicHeight(child) + mPaddingBetweenElements;
                if (startingPosition < getOwnScrollY()) {
                    // This child starts off screen, so let's keep it offscreen to keep the
                    // others visible

                    setOwnScrollY(getOwnScrollY() + childHeight);
                }
            }
        }
        clampScrollPosition();
    }

    public void setApplyHunTranslation(boolean apply) {
        mAmbientState.setApplyHunTranslation(apply);
    }

    private void updateForcedScroll() {
        if (mForcedScroll != null && (!mForcedScroll.hasFocus()
                || !mForcedScroll.isAttachedToWindow())) {
            mForcedScroll = null;
        }
        if (mForcedScroll != null) {
            ExpandableView expandableView = (ExpandableView) mForcedScroll;
            int positionInLinearLayout = getPositionInLinearLayout(expandableView);
            int targetScroll = targetScrollForView(expandableView, positionInLinearLayout);
            int outOfViewScroll = positionInLinearLayout + expandableView.getIntrinsicHeight();
            targetScroll = Math.max(0, Math.min(targetScroll, getScrollRange()));
            // Only apply the scroll if we're scrolling the view upwards, or the view is so
            // far up that it is not visible anymore.
            if (getOwnScrollY() < targetScroll || outOfViewScroll < getOwnScrollY()) {
                setOwnScrollY(targetScroll);
            }
        }
    }

    void requestChildrenUpdate() {
        if (!mChildrenUpdateRequested) {
            getViewTreeObserver().addOnPreDrawListener(mChildrenUpdater);
            mChildrenUpdateRequested = true;
            invalidate();
        }
    }

    @Override
    public void setAlpha(float alpha) {
        super.setAlpha(alpha);
        if (Trace.isEnabled()) {
            Trace.setCounter(
                    trackGroup(/* groupName= */ "shade", /* trackName= */ "NSSLResultingAlpha"),
                    (int) (alpha * 100));
        }
    }

    private boolean isCurrentlyAnimating() {
        return mStateAnimator.isRunning();
    }

    private void clampScrollPosition() {
        // NSSL doesn't control scrolling with SceneContainer enabled
        if (SceneContainerFlag.isEnabled()) {
            return;
        }
        int scrollRange = getScrollRange();
        if (scrollRange < getOwnScrollY() && !mAmbientState.isClearAllInProgress()) {
            // if the scroll boundary updates the position of the stack,
            boolean animateStackY = scrollRange < getScrollAmountToScrollBoundary()
                    && mAnimateStackYForContentHeightChange;
            setOwnScrollY(scrollRange, animateStackY);
        }
    }

    public int getTopPadding() {
        SceneContainerFlag.assertInLegacyMode();
        return mAmbientState.getTopPadding();
    }

    private void onTopPaddingChanged(boolean animate) {
        boolean shouldAnimate = animate || mAnimateNextTopPaddingChange;
        updateAlgorithmHeightAndPadding();
        updateContentHeight();
        if (mAmbientState.isOnKeyguard()
                && !mShouldUseSplitNotificationShade
                && mShouldSkipTopPaddingAnimationAfterFold) {
            mShouldSkipTopPaddingAnimationAfterFold = false;
        } else if (shouldAnimate && mAnimationsEnabled && mIsExpanded) {
            mTopPaddingNeedsAnimation = true;
            mNeedsAnimation = true;
        }
        updateStackPosition();
        requestChildrenUpdate();
        notifyHeightChangeListener(null, shouldAnimate);
        mAnimateNextTopPaddingChange = false;
    }

    /**
     * Apply expansion fraction to the y position and height of the notifications panel.
     */
    private void updateStackPosition() {
        updateStackPosition(false /* listenerNeedsAnimation */);
    }

    /**
     * @return Whether we should skip stack height updates.
     * True when
     * 1) Unlock hint is running
     * 2) Swiping up on lockscreen or flinging down after swipe up
     * 3) When transiting between the expanded QS and the single Shade.
     */
    private boolean shouldSkipHeightUpdate() {
        if (SceneContainerFlag.isEnabled()) {
            return mSuppressHeightUpdates;
        } else {
            return mAmbientState.isOnKeyguard()
                    && (mAmbientState.isSwipingUp()
                    || mAmbientState.isFlingingAfterSwipeUpOnLockscreen());
        }
    }

    /**
     * Apply expansion fraction to the y position and height of the notifications panel.
     *
     * @param listenerNeedsAnimation does the listener need to animate?
     */
    private void updateStackPosition(boolean listenerNeedsAnimation) {
        // When scene container is active, we only want to recalculate stack heights.
        if (SceneContainerFlag.isEnabled()) {
            updateStackEndHeightAndStackHeight(mAmbientState.getExpansionFraction());
            return;
        }
        float topOverscrollAmount = mShouldUseSplitNotificationShade
                ? getCurrentOverScrollAmount(true /* top */) : 0f;
        final float endTopPosition = getTopPadding() + mExtraTopInsetForFullShadeTransition
                + mAmbientState.getOverExpansion()
                + topOverscrollAmount
                - getCurrentOverScrollAmount(false /* top */);
        float fraction = mAmbientState.getExpansionFraction();
        // If we are on quick settings, we need to quickly hide it to show the bouncer to avoid an
        // overlap. Otherwise, we maintain the normal fraction for smoothness.
        if (mAmbientState.isBouncerInTransit() && getQsExpansionFraction() > 0f) {
            fraction = BouncerPanelExpansionCalculator.aboutToShowBouncerProgress(fraction);
        }
        final float stackY = MathUtils.lerp(0, endTopPosition, fraction);
        mAmbientState.setStackY(stackY);

        if (mOnStackYChanged != null) {
            mOnStackYChanged.accept(listenerNeedsAnimation);
        }
        updateStackEndHeightAndStackHeight(fraction);
    }

    @VisibleForTesting
    public void updateStackEndHeightAndStackHeight(float fraction) {
        final float oldStackHeight = mAmbientState.getInterpolatedStackHeight();
        if (SceneContainerFlag.isEnabled()) {
            final float endHeight;
            if (!shouldSkipHeightUpdate()) {
                endHeight = updateStackEndHeight();
            } else {
                endHeight = mAmbientState.getStackEndHeight();
            }
            updateInterpolatedStackHeight(endHeight, fraction);
        } else {
            if (getQsExpansionFraction() <= 0 && !shouldSkipHeightUpdate()) {
                final float endHeight = updateStackEndHeight(
                        getHeight(), getEmptyBottomMarginInternal(), getTopPadding());
                updateInterpolatedStackHeight(endHeight, fraction);
            } else {
                // Always updateStackHeight to prevent jumps in the stack height when this fraction
                // suddenly reapplies after a freeze.
                final float endHeight = mAmbientState.getStackEndHeight();
                updateInterpolatedStackHeight(endHeight, fraction);
            }
        }
        if (oldStackHeight != mAmbientState.getInterpolatedStackHeight()) {
            requestChildrenUpdate();
        }
    }

    private float updateStackEndHeight() {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return 0f;
        final float height;
        if (mMaxDisplayedNotifications != -1) {
            // The stack intrinsic height already contains the correct value when there is a limit
            // in the max number of notifications (e.g. as in keyguard).
            height = mScrollViewFields.intrinsicStackHeight;
        } else {
            height = Math.max(0f, mAmbientState.getStackCutoff() - mAmbientState.getStackTop());
        }
        mAmbientState.setStackEndHeight(height);
        return height;
    }

    private float updateStackEndHeight(float height, float bottomMargin, float topPadding) {
        SceneContainerFlag.assertInLegacyMode();
        final float stackEndHeight;
        if (mMaxDisplayedNotifications != -1) {
            // The stack intrinsic height already contains the correct value when there is a limit
            // in the max number of notifications (e.g. as in keyguard).
            stackEndHeight = getIntrinsicContentHeight();
        } else {
            stackEndHeight = Math.max(0f, height - bottomMargin - topPadding);
        }
        mAmbientState.setStackEndHeight(stackEndHeight);
        return stackEndHeight;
    }

    @VisibleForTesting
    public void updateInterpolatedStackHeight(float endHeight, float fraction) {
        mAmbientState.setInterpolatedStackHeight(
                calculateInterpolatedStackHeight(endHeight, fraction));
    }

    @VisibleForTesting
    float calculateInterpolatedStackHeight(float endHeight, float fraction) {
        return MathUtils.lerp(endHeight * StackScrollAlgorithm.START_FRACTION,
                endHeight, fraction);
    }

    /**
     * Add a listener when the StackY changes. The argument signifies whether an animation is
     * needed.
     */
    void setOnStackYChanged(Consumer<Boolean> onStackYChanged) {
        SceneContainerFlag.assertInLegacyMode();
        mOnStackYChanged = onStackYChanged;
    }

    @Override
    public void setExpandFraction(float expandFraction) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        final float oldFraction = mAmbientState.getExpansionFraction();
        final boolean wasExpanding = oldFraction != 0f && oldFraction != 1f;
        final boolean nowExpanding = expandFraction != 0f && expandFraction != 1f;

        // need to enter 'expanding' state before handling the new expand fraction, and then
        if (nowExpanding && !wasExpanding) {
            onExpansionStarted();
            mController.checkSnoozeLeavebehind();
        }

        // Update the expand progress between started/stopped events
        mAmbientState.setExpansionFraction(expandFraction);

        if (!shouldSkipHeightUpdate()) {
            updateStackEndHeightAndStackHeight(expandFraction);
            updateExpandedHeight(expandFraction);
        }

        // expansion stopped event requires that the expandFraction has already been updated
        if (!nowExpanding && wasExpanding) {
            setCheckForLeaveBehind(false);
            onExpansionStopped();
        }
    }

    private void updateExpandedHeight(float expandFraction) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        float expandedHeight = expandFraction * getHeight();
        setIsExpanded(expandedHeight > 0);

        if (mExpandedHeight != expandedHeight) {
            mExpandedHeight = expandedHeight;
            updateAlgorithmHeightAndPadding();
            requestChildrenUpdate();
            notifyAppearChangedListeners();
        }
    }

    @Override
    public void setQsExpandFraction(float expandFraction) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mAmbientState.setQsExpansionFraction(expandFraction);
    }

    /**
     * Update the height of the panel.
     *
     * @param height the expanded height of the panel
     */
    public void setExpandedHeight(float height) {
        SceneContainerFlag.assertInLegacyMode();
        final boolean skipHeightUpdate = shouldSkipHeightUpdate();

        updateStackPosition();

        if (!skipHeightUpdate) {
            mExpandedHeight = height;
            setIsExpanded(height > 0);
            int minExpansionHeight = getMinExpansionHeight();
            if (height < minExpansionHeight && !mShouldUseSplitNotificationShade) {
                mClipRect.left = 0;
                mClipRect.right = getWidth();
                mClipRect.top = 0;
                mClipRect.bottom = (int) height;
                height = minExpansionHeight;
                setRequestedClipBounds(mClipRect);
            } else {
                setRequestedClipBounds(null);
            }
        }
        int stackHeight;
        float translationY;
        float appearFraction = 1.0f;
        boolean appearing = calculateAppearFraction(height) < 1;
        if (!appearing) {
            translationY = 0;
            if (mShouldShowShelfOnly) {
                stackHeight = getTopPadding() + mShelf.getIntrinsicHeight();
            } else if (mQsFullScreen) {
                int stackStartPosition =
                        getContentHeight() - getTopPadding() + getIntrinsicPadding();
                int stackEndPosition = getMaxTopPadding() + mShelf.getIntrinsicHeight();
                if (stackStartPosition <= stackEndPosition) {
                    stackHeight = stackEndPosition;
                } else {
                    if (mShouldUseSplitNotificationShade) {
                        // This prevents notifications from being collapsed when QS is expanded.
                        stackHeight = (int) height;
                    } else {
                        stackHeight = (int) NotificationUtils.interpolate(stackStartPosition,
                                stackEndPosition, getQsExpansionFraction());
                    }
                }
            } else {
                stackHeight = (int) (skipHeightUpdate ? mExpandedHeight : height);
            }
        } else {
            appearFraction = calculateAppearFraction(height);
            if (appearFraction >= 0) {
                translationY = NotificationUtils.interpolate(getExpandTranslationStart(), 0,
                        appearFraction);
            } else {
                // This may happen when pushing up a heads up. We linearly push it up from the
                // start
                translationY = height - getAppearStartPosition() + getExpandTranslationStart();
            }
            stackHeight = (int) (height - translationY);
            if (isHeadsUpTransition() && appearFraction >= 0) {
                int topSpacing = mShouldUseSplitNotificationShade
                        ? mAmbientState.getStackTopMargin() : getTopPadding();
                float startPos = mHeadsUpInset - topSpacing;
                translationY = MathUtils.lerp(startPos, 0, appearFraction);
            }
        }
        mAmbientState.setAppearFraction(appearFraction);
        if (stackHeight != mCurrentStackHeight && !skipHeightUpdate) {
            mCurrentStackHeight = stackHeight;
            updateAlgorithmHeightAndPadding();
            requestChildrenUpdate();
        }
        setStackTranslation(translationY);
        notifyAppearChangedListeners();
    }

    private void notifyAppearChangedListeners() {
        float appear;
        float expandAmount;
        if (mKeyguardBypassEnabled && onKeyguard()) {
            appear = calculateAppearFractionBypassInternal();
            expandAmount = getPulseHeight();
        } else {
            appear = MathUtils.saturate(calculateAppearFraction(mExpandedHeight));
            expandAmount = mExpandedHeight;
        }
        if (appear != mLastSentAppear || expandAmount != mLastSentExpandedHeight) {
            mLastSentAppear = appear;
            mLastSentExpandedHeight = expandAmount;
            for (int i = 0; i < mExpandedHeightListeners.size(); i++) {
                BiConsumer<Float, Float> listener = mExpandedHeightListeners.get(i);
                listener.accept(expandAmount, appear);
            }
        }
    }

    private void setRequestedClipBounds(Rect clipRect) {
        mRequestedClipBounds = clipRect;
        updateClipping();
    }

    public void updateClipping() {
        boolean clipped = mRequestedClipBounds != null && !mInHeadsUpPinnedMode
                && !mHeadsUpAnimatingAway;
        if (mIsClipped != clipped) {
            mIsClipped = clipped;
        }

        if (mAmbientState.isHiddenAtAll()) {
            invalidateOutline();
            if (isFullyHidden()) {
                setClipBounds(null);
            }
        } else if (clipped) {
            setClipBounds(mRequestedClipBounds);
        } else {
            setClipBounds(null);
        }

        setClipToOutline(false);
    }

    /**
     * @return The translation at the beginning when expanding.
     * Measured relative to the resting position.
     */
    private float getExpandTranslationStart() {
        SceneContainerFlag.assertInLegacyMode();
        return -getTopPadding() + getMinExpansionHeight() - mShelf.getIntrinsicHeight();
    }

    /**
     * @return the position from where the appear transition starts when expanding.
     * Measured in absolute height.
     */
    private float getAppearStartPosition() {
        SceneContainerFlag.assertInLegacyMode();
        if (isHeadsUpTransition()) {
            final NotificationSection firstVisibleSection = getFirstVisibleSection();
            final int pinnedHeight = firstVisibleSection != null
                    ? firstVisibleSection.getFirstVisibleChild().getPinnedHeadsUpHeight()
                    : 0;
            return mHeadsUpInset - mAmbientState.getStackTopMargin() + pinnedHeight;
        }
        return getMinExpansionHeight();
    }

    /**
     * @return the height of the top heads up notification when pinned. This is different from the
     * intrinsic height, which also includes whether the notification is system expanded and
     * is mainly used when dragging down from a heads up notification.
     */
    private int getTopHeadsUpPinnedHeight() {
        if (mTopHeadsUpRow == null) {
            return 0;
        }
        ExpandableNotificationRow row = getTopHeadsUpRow();
        return row.getPinnedHeadsUpHeight();
    }

    private int getTopHeadsUpIntrinsicHeight() {
        if (mTopHeadsUpRow == null) {
            return 0;
        }
        ExpandableNotificationRow row = getTopHeadsUpRow();
        return row.getIntrinsicHeight();
    }

    private ExpandableNotificationRow getTopHeadsUpRow() {
        ExpandableNotificationRow row = mTopHeadsUpRow;
        if (NotificationBundleUi.isEnabled()) {
            if (mGroupMembershipManager.isChildInGroup(row.getEntryAdapter())
                    && row.isChildInGroup()) {
                row = row.getNotificationParent();
            }
        } else {
            if (row.isChildInGroup()) {
                final NotificationEntry groupSummary =
                        mGroupMembershipManager.getGroupSummary(row.getEntryLegacy());
                if (groupSummary != null) {
                    row = groupSummary.getRow();
                }
            }
        }
        return row;
    }
    /**
     * @return the position from where the appear transition ends when expanding.
     * Measured in absolute height.
     *
     * TODO(b/308591475): This entire logic can probably be improved as part of the empty shade
     *  refactor, but for now:
     *  - if the empty shade is visible, we can assume that the visible notif count is not 0;
     *  - if the shelf is visible, we can assume there are at least 2 notifications present (this
     *    is not true for AOD, but this logic refers to the expansion of the shade, where we never
     *    have the shelf on its own)
     */
    private float getAppearEndPosition() {
        SceneContainerFlag.assertInLegacyMode();

        int appearPosition = mAmbientState.getStackTopMargin();
        if (mEmptyShadeView.getVisibility() == GONE) {
            if (isHeadsUpTransition()
                    || (mInHeadsUpPinnedMode && !mAmbientState.isDozing())) {
                if (mShelf.getVisibility() != GONE) {
                    appearPosition += mShelf.getIntrinsicHeight() + mPaddingBetweenElements;
                }
                appearPosition += getTopHeadsUpPinnedHeight()
                        + getPositionInLinearLayout(mAmbientState.getTrackedHeadsUpRow());
            } else if (mShelf.getVisibility() != GONE) {
                appearPosition += mShelf.getIntrinsicHeight();
            }
        } else {
            appearPosition = mEmptyShadeView.getHeight();
        }
        return appearPosition + (onKeyguard() ? getTopPadding() : getIntrinsicPadding());
    }

    private boolean isHeadsUpTransition() {
        return mAmbientState.getTrackedHeadsUpRow() != null;
    }

    /**
     * @param height the height of the panel
     * @return Fraction of the appear animation that has been performed. Normally follows expansion
     * fraction so goes from 0 to 1, the only exception is HUN where it can go negative, down to -1,
     * when HUN is swiped up.
     */
    @FloatRange(from = -1.0, to = 1.0)
    public float calculateAppearFraction(float height) {
        if (isHeadsUpTransition() && !SceneContainerFlag.isEnabled()) {
            // HUN is a special case because fraction can go negative if swiping up. And for now
            // it must go negative as other pieces responsible for proper translation up assume
            // negative value for HUN going up.
            // This can't use expansion fraction as that goes only from 0 to 1. Also when
            // appear fraction for HUN is 0, expansion fraction will be already around 0.2-0.3
            // and that makes translation jump immediately.
            float appearEndPosition = getAppearEndPosition();
            float appearStartPosition = getAppearStartPosition();
            float hunAppearFraction = (height - appearStartPosition)
                    / (appearEndPosition - appearStartPosition);
            return MathUtils.constrain(hunAppearFraction, -1, 1);
        } else {
            return mAmbientState.getExpansionFraction();
        }
    }

    public float getStackTranslation() {
        return mAmbientState.getStackTranslation();
    }

    private void setStackTranslation(float stackTranslation) {
        if (stackTranslation != getStackTranslation()) {
            mAmbientState.setStackTranslation(stackTranslation);
            requestChildrenUpdate();
        }
    }

    /**
     * Get the current height of the view. This is at most the msize of the view given by a the
     * layout but it can also be made smaller by setting {@link #mCurrentStackHeight}
     *
     * @return either the layout height or the externally defined height, whichever is smaller
     */
    private int getLayoutHeight() {
        return Math.min(mMaxLayoutHeight, mCurrentStackHeight);
    }

    public void setQsHeader(ViewGroup qsHeader) {
        QSComposeFragment.assertInLegacyMode();
        mQsHeader = qsHeader;
    }

    public void setQsHeaderBoundsProvider(QSHeaderBoundsProvider qsHeaderBoundsProvider) {
        SceneContainerFlag.assertInLegacyMode();
        QSComposeFragment.isUnexpectedlyInLegacyMode();
        mQSHeaderBoundsProvider = qsHeaderBoundsProvider;
    }

    public static boolean isPinnedHeadsUp(View v) {
        if (v instanceof ExpandableNotificationRow row) {
            return row.isHeadsUp() && row.isPinned();
        }
        return false;
    }

    private boolean isHeadsUp(View v) {
        if (v instanceof ExpandableNotificationRow row) {
            return row.isHeadsUp();
        }
        return false;
    }

    private ExpandableView getChildAtPosition(float touchX, float touchY) {
        return getChildAtPosition(touchX, touchY, true /* requireMinHeight */,
                true /* ignoreDecors */, true /* ignoreWidth */);
    }

    /**
     * Get the child at a certain screen location.
     *
     * @param touchX           the x coordinate
     * @param touchY           the y coordinate
     * @param requireMinHeight Whether a minimum height is required for a child to be returned.
     * @param ignoreDecors     Whether decors can be returned
     * @param ignoreWidth      Whether we should ignore the width of the child
     * @return the child at the given location.
     */
    ExpandableView getChildAtPosition(float touchX, float touchY, boolean requireMinHeight,
            boolean ignoreDecors, boolean ignoreWidth) {
        // find the view under the pointer, accounting for GONE views
        final int count = getChildCount();
        for (int childIdx = 0; childIdx < count; childIdx++) {
            ExpandableView slidingChild = getChildAtIndex(childIdx);
            if (slidingChild.getVisibility() != VISIBLE
                    || (ignoreDecors && slidingChild instanceof StackScrollerDecorView)) {
                continue;
            }
            float childTop = slidingChild.getTranslationY();
            float top = childTop + Math.max(Math.max(0, slidingChild.getClipTopAmount()),
                    slidingChild.getTopOverlap());
            int clipBottomAmount = Math.max(slidingChild.getClipBottomAmount(),
                    slidingChild.getBottomOverlap());
            float bottom = childTop + slidingChild.getActualHeight() - clipBottomAmount;

            // Allow the full width of this view to prevent gesture conflict on Keyguard (phone and
            // camera affordance).
            int left = ignoreWidth ? 0 : slidingChild.getLeft();
            int right = ignoreWidth ? getWidth() : slidingChild.getRight();

            if ((bottom - top >= mMinInteractionHeight || !requireMinHeight)
                    && touchY >= top && touchY <= bottom && touchX >= left && touchX <= right) {
                if (slidingChild instanceof ExpandableNotificationRow row) {
                    boolean isEntrySummaryForTopHun;
                    if (NotificationBundleUi.isEnabled()) {
                        isEntrySummaryForTopHun = Objects.equals(
                                ((ExpandableNotificationRow) slidingChild).getNotificationParent(),
                                mTopHeadsUpRow);
                    } else {
                        NotificationEntry entry = row.getEntryLegacy();
                        isEntrySummaryForTopHun = mTopHeadsUpRow != null &&
                                mGroupMembershipManager.getGroupSummary(
                                        mTopHeadsUpRow.getEntryLegacy()) == entry;
                    }
                    if (!mIsExpanded && row.isHeadsUp() && row.isPinned()
                            && mTopHeadsUpRow != row
                            && !isEntrySummaryForTopHun) {
                        continue;
                    }
                    return row.getViewAtPosition(touchY - childTop);
                }
                return slidingChild;
            }
        }
        return null;
    }

    private ExpandableView getChildAtIndex(int index) {
        return (ExpandableView) getChildAt(index);
    }

    public ExpandableView getChildAtRawPosition(float touchX, float touchY) {
        getLocationOnScreen(mTempInt2);
        return getChildAtPosition(touchX - mTempInt2[0], touchY - mTempInt2[1]);
    }

    public void setScrollingEnabled(boolean enable) {
        mScrollingEnabled = enable;
    }

    public void lockScrollTo(View v) {
        // NSSL shouldn't handle scrolling with SceneContainer enabled.
        if (mForcedScroll == v || SceneContainerFlag.isEnabled()) {
            return;
        }
        mForcedScroll = v;
        updateForcedScroll();
    }

    public boolean scrollTo(View v) {
        // NSSL shouldn't handle scrolling with SceneContainer enabled.
        if (SceneContainerFlag.isEnabled()) {
            return false;
        }
        ExpandableView expandableView = (ExpandableView) v;
        int positionInLinearLayout = getPositionInLinearLayout(v);
        int targetScroll = targetScrollForView(expandableView, positionInLinearLayout);
        int outOfViewScroll = positionInLinearLayout + expandableView.getIntrinsicHeight();

        // Only apply the scroll if we're scrolling the view upwards, or the view is so far up
        // that it is not visible anymore.
        if (getOwnScrollY() < targetScroll || outOfViewScroll < getOwnScrollY()) {
            mScroller.startScroll(mScrollX, getOwnScrollY(), 0, targetScroll - getOwnScrollY());
            mDontReportNextOverScroll = true;
            animateScroll();
            return true;
        }
        return false;
    }

    /**
     * @return the scroll necessary to make the bottom edge of {@param v} align with the top of
     * the IME.
     */
    private int targetScrollForView(ExpandableView v, int positionInLinearLayout) {
        SceneContainerFlag.assertInLegacyMode();
        return positionInLinearLayout + v.getIntrinsicHeight() +
                getImeInset() - getHeight()
                + ((!isExpanded() && isPinnedHeadsUp(v)) ? mHeadsUpInset : getTopPadding());
    }

    private void updateImeInset(WindowInsets windowInsets) {
        SceneContainerFlag.assertInLegacyMode();
        mImeInset = windowInsets.getInsets(WindowInsets.Type.ime()).bottom;

        if (mFooterView != null && mFooterView.getViewState() != null) {
            // Do not animate footer Y when showing IME so that after IME hides, the footer
            // appears at the correct Y. Once resetY is true, it remains true (even when IME
            // hides, where mImeInset=0) until reset in FooterViewState#animateTo.
            ((FooterView.FooterViewState) mFooterView.getViewState()).resetY |= mImeInset > 0;
        }

        if (mForcedScroll != null) {
            updateForcedScroll();
        }

        int range = getScrollRange();
        if (getOwnScrollY() > range) {
            setOwnScrollY(range);
        }
    }

    @Override
    public WindowInsets onApplyWindowInsets(WindowInsets insets) {
        mWaterfallTopInset = 0;
        final DisplayCutout cutout = insets.getDisplayCutout();
        if (cutout != null) {
            mWaterfallTopInset = cutout.getWaterfallInsets().top;
        }
        if (!SceneContainerFlag.isEnabled() && !mIsInsetAnimationRunning) {
            // update bottom inset e.g. after rotation
            updateImeInset(insets);
        }
        return insets;
    }

    public void setExpandingEnabled(boolean enable) {
        mExpandHelper.setEnabled(enable);
    }

    private boolean isScrollingEnabled() {
        return mScrollingEnabled;
    }

    boolean onKeyguard() {
        return mStatusBarState == StatusBarState.KEYGUARD;
    }

    @Override
    protected void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        Resources res = getResources();
        updateSplitNotificationShade();
        mStatusBarHeight = SystemBarUtils.getStatusBarHeight(mContext);
        float pagingTouchSlop = ViewConfiguration.get(getContext()).getScaledPagingTouchSlop();
        mSwipeHelper.setPagingTouchSlop(pagingTouchSlop);
        reinitView();
    }

    public void dismissViewAnimated(
            View child, Consumer<Boolean> endRunnable, int delay, long duration) {
        if (child instanceof SectionHeaderView) {
            ((StackScrollerDecorView) child).setContentVisible(
                    false /* visible */, true /* animate */, endRunnable);
            return;
        }
        mSwipeHelper.dismissChild(
                child,
                0 /* velocity */,
                endRunnable,
                delay,
                true /* useAccelerateInterpolator */,
                duration,
                true /* isClearAll */);
    }

    private void snapViewIfNeeded(NotificationEntry entry) {
        ExpandableNotificationRow child = entry.getRow();
        boolean animate = mIsExpanded || isPinnedHeadsUp(child);
        // If the child is showing the notification menu snap to that
        if (child.getProvider() != null) {
            float targetLeft = child.getProvider().isMenuVisible() ? child.getTranslation() : 0;
            mSwipeHelper.snapChildIfNeeded(child, animate, targetLeft);
        }
    }

    public ViewGroup getViewParentForNotification() {
        return this;
    }

    /**
     * Perform a scroll upwards and adapt the overscroll amounts accordingly
     *
     * @param deltaY The amount to scroll upwards, has to be positive.
     * @return The amount of scrolling to be performed by the scroller,
     * not handled by the overScroll amount.
     */
    private float overScrollUp(int deltaY, int range) {
        deltaY = Math.max(deltaY, 0);
        float currentTopAmount = getCurrentOverScrollAmount(true);
        float newTopAmount = currentTopAmount - deltaY;
        if (currentTopAmount > 0) {
            setOverScrollAmount(newTopAmount, true /* onTop */,
                    false /* animate */);
        }
        // Top overScroll might not grab all scrolling motion,
        // we have to scroll as well.
        float scrollAmount = newTopAmount < 0 ? -newTopAmount : 0.0f;
        float newScrollY = getOwnScrollY() + scrollAmount;
        if (newScrollY > range) {
            if (!mExpandedInThisMotion) {
                float currentBottomPixels = getCurrentOverScrolledPixels(false);
                // We overScroll on the bottom
                setOverScrolledPixels(currentBottomPixels + newScrollY - range,
                        false /* onTop */,
                        false /* animate */);
            }
            setOwnScrollY(range);
            scrollAmount = 0.0f;
        }
        return scrollAmount;
    }

    /**
     * Perform a scroll downward and adapt the overscroll amounts accordingly
     *
     * @param deltaY The amount to scroll downwards, has to be negative.
     * @return The amount of scrolling to be performed by the scroller,
     * not handled by the overScroll amount.
     */
    private float overScrollDown(int deltaY) {
        deltaY = Math.min(deltaY, 0);
        float currentBottomAmount = getCurrentOverScrollAmount(false);
        float newBottomAmount = currentBottomAmount + deltaY;
        if (currentBottomAmount > 0) {
            setOverScrollAmount(newBottomAmount, false /* onTop */,
                    false /* animate */);
        }
        // Bottom overScroll might not grab all scrolling motion,
        // we have to scroll as well.
        float scrollAmount = newBottomAmount < 0 ? newBottomAmount : 0.0f;
        float newScrollY = getOwnScrollY() + scrollAmount;
        if (newScrollY < 0) {
            float currentTopPixels = getCurrentOverScrolledPixels(true);
            // We overScroll on the top
            setOverScrolledPixels(currentTopPixels - newScrollY,
                    true /* onTop */,
                    false /* animate */);
            setOwnScrollY(0);
            scrollAmount = 0.0f;
        }
        return scrollAmount;
    }

    private void initVelocityTrackerIfNotExists() {
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
    }

    private void recycleVelocityTracker() {
        if (mVelocityTracker != null) {
            mVelocityTracker.recycle();
            mVelocityTracker = null;
        }
    }

    private void initOrResetVelocityTracker() {
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        } else {
            mVelocityTracker.clear();
        }
    }

    public void setFinishScrollingCallback(Runnable runnable) {
        SceneContainerFlag.assertInLegacyMode();
        mFinishScrollingCallback = runnable;
    }

    private void animateScroll() {
        if (mScroller.computeScrollOffset()) {
            int oldY = getOwnScrollY();
            int y = mScroller.getCurrY();

            if (oldY != y) {
                int range = getScrollRange();
                if (y < 0 && oldY >= 0 || y > range && oldY <= range) {
                    // This frame takes us into overscroll, so set the max overscroll based on
                    // the current velocity
                    setMaxOverScrollFromCurrentVelocity();
                }

                if (mDontClampNextScroll) {
                    range = Math.max(range, oldY);
                }
                customOverScrollBy(y - oldY, oldY, range,
                        (int) (mMaxOverScroll));
            }
            postOnAnimation(mReflingAndAnimateScroll);
        } else {
            mDontClampNextScroll = false;
            if (mFinishScrollingCallback != null) {
                mFinishScrollingCallback.run();
            }
        }
    }

    private void setMaxOverScrollFromCurrentVelocity() {
        float currVelocity = mScroller.getCurrVelocity();
        if (currVelocity >= mMinimumVelocity) {
            mMaxOverScroll = Math.abs(currVelocity) / 1000 * mOverflingDistance;
        }
    }

    /**
     * Scrolls by the given delta, overscrolling if needed.  If called during a fling and the delta
     * would cause us to exceed the provided maximum overscroll, springs back instead.
     * <p>
     * This method performs the determination of whether we're exceeding the overscroll and clamps
     * the scroll amount if so.  The actual scrolling/overscrolling happens in
     * {@link #onCustomOverScrolled(int, boolean)}
     *
     * @param deltaY         The (signed) number of pixels to scroll.
     * @param scrollY        The current scroll position (absolute scrolling only).
     * @param scrollRangeY   The maximum allowable scroll position (absolute scrolling only).
     * @param maxOverScrollY The current (unsigned) limit on number of pixels to overscroll by.
     */
    private void customOverScrollBy(int deltaY, int scrollY, int scrollRangeY, int maxOverScrollY) {
        int newScrollY = scrollY + deltaY;
        final int top = -maxOverScrollY;
        final int bottom = maxOverScrollY + scrollRangeY;

        boolean clampedY = false;
        if (newScrollY > bottom) {
            newScrollY = bottom;
            clampedY = true;
        } else if (newScrollY < top) {
            newScrollY = top;
            clampedY = true;
        }

        onCustomOverScrolled(newScrollY, clampedY);
    }

    /**
     * Set the amount of overScrolled pixels which will force the view to apply a rubber-banded
     * overscroll effect based on numPixels. By default this will also cancel animations on the
     * same overScroll edge.
     *
     * @param numPixels The amount of pixels to overScroll by. These will be scaled according to
     *                  the rubber-banding logic.
     * @param onTop     Should the effect be applied on top of the scroller.
     * @param animate   Should an animation be performed.
     */
    public void setOverScrolledPixels(float numPixels, boolean onTop, boolean animate) {
        setOverScrollAmount(numPixels * getRubberBandFactor(onTop), onTop, animate, true);
    }

    /**
     * Set the effective overScroll amount which will be directly reflected in the layout.
     * By default this will also cancel animations on the same overScroll edge.
     *
     * @param amount  The amount to overScroll by.
     * @param onTop   Should the effect be applied on top of the scroller.
     * @param animate Should an animation be performed.
     */

    public void setOverScrollAmount(float amount, boolean onTop, boolean animate) {
        setOverScrollAmount(amount, onTop, animate, true);
    }

    /**
     * Set the effective overScroll amount which will be directly reflected in the layout.
     *
     * @param amount          The amount to overScroll by.
     * @param onTop           Should the effect be applied on top of the scroller.
     * @param animate         Should an animation be performed.
     * @param cancelAnimators Should running animations be cancelled.
     */
    public void setOverScrollAmount(float amount, boolean onTop, boolean animate,
                                    boolean cancelAnimators) {
        setOverScrollAmount(amount, onTop, animate, cancelAnimators, isRubberbanded(onTop));
    }

    /**
     * Set the effective overScroll amount which will be directly reflected in the layout.
     *
     * @param amount          The amount to overScroll by.
     * @param onTop           Should the effect be applied on top of the scroller.
     * @param animate         Should an animation be performed.
     * @param cancelAnimators Should running animations be cancelled.
     * @param isRubberbanded  The value which will be passed to
     *                        {@link OnOverscrollTopChangedListener#onOverscrollTopChanged}
     */
    public void setOverScrollAmount(float amount, boolean onTop, boolean animate,
                                    boolean cancelAnimators, boolean isRubberbanded) {
        if (cancelAnimators) {
            mStateAnimator.cancelOverScrollAnimators(onTop);
        }
        setOverScrollAmountInternal(amount, onTop, animate, isRubberbanded);
    }

    private void setOverScrollAmountInternal(float amount, boolean onTop, boolean animate,
                                             boolean isRubberbanded) {
        SceneContainerFlag.assertInLegacyMode();
        amount = Math.max(0, amount);
        if (animate) {
            mStateAnimator.animateOverScrollToAmount(amount, onTop, isRubberbanded);
        } else {
            setOverScrolledPixels(amount / getRubberBandFactor(onTop), onTop);
            mAmbientState.setOverScrollAmount(amount, onTop);
            if (onTop) {
                notifyOverscrollTopListener(amount, isRubberbanded);
            }
            updateStackPosition();
            requestChildrenUpdate();
        }
    }

    private void notifyOverscrollTopListener(float amount, boolean isRubberbanded) {
        mExpandHelper.onlyObserveMovements(amount > 1.0f);
        if (mDontReportNextOverScroll) {
            mDontReportNextOverScroll = false;
            return;
        }
        if (mOverscrollTopChangedListener != null) {
            mOverscrollTopChangedListener.onOverscrollTopChanged(amount, isRubberbanded);
        }
    }

    public void setOverscrollTopChangedListener(
            OnOverscrollTopChangedListener overscrollTopChangedListener) {
        SceneContainerFlag.assertInLegacyMode();
        mOverscrollTopChangedListener = overscrollTopChangedListener;
    }

    public float getCurrentOverScrollAmount(boolean top) {
        return mAmbientState.getOverScrollAmount(top);
    }

    public float getCurrentOverScrolledPixels(boolean top) {
        return top ? mOverScrolledTopPixels : mOverScrolledBottomPixels;
    }

    private void setOverScrolledPixels(float amount, boolean onTop) {
        if (onTop) {
            mOverScrolledTopPixels = amount;
        } else {
            mOverScrolledBottomPixels = amount;
        }
    }

    /**
     * Scrolls to the given position, overscrolling if needed.  If called during a fling and the
     * position exceeds the provided maximum overscroll, springs back instead.
     *
     * @param scrollY  The target scroll position.
     * @param clampedY Whether this value was clamped by the calling method, meaning we've reached
     *                 the overscroll limit.
     */
    private void onCustomOverScrolled(int scrollY, boolean clampedY) {
        // Treat animating scrolls differently; see #computeScroll() for why.
        if (!mScroller.isFinished()) {
            setOwnScrollY(scrollY);
            if (clampedY) {
                springBack();
            } else {
                float overScrollTop = getCurrentOverScrollAmount(true);
                if (getOwnScrollY() < 0) {
                    notifyOverscrollTopListener(-getOwnScrollY(), isRubberbanded(true));
                } else {
                    notifyOverscrollTopListener(overScrollTop, isRubberbanded(true));
                }
            }
        } else {
            setOwnScrollY(scrollY);
        }
    }

    /**
     * Springs back from an overscroll by stopping the {@link #mScroller} and animating the
     * overscroll amount back to zero.
     */
    private void springBack() {
        int scrollRange = getScrollRange();
        boolean overScrolledTop = getOwnScrollY() <= 0;
        boolean overScrolledBottom = getOwnScrollY() >= scrollRange;
        if (overScrolledTop || overScrolledBottom) {
            boolean onTop;
            float newAmount;
            if (overScrolledTop) {
                onTop = true;
                newAmount = -getOwnScrollY();
                setOwnScrollY(0);
                mDontReportNextOverScroll = true;
            } else {
                onTop = false;
                newAmount = getOwnScrollY() - scrollRange;
                setOwnScrollY(scrollRange);
            }
            setOverScrollAmount(newAmount, onTop, false);
            setOverScrollAmount(0.0f, onTop, true);
            mScroller.forceFinished(true);
        }
    }

    private int getScrollRange() {
        // TODO(b/360091533) Disable the usages of #getScrollRange() with SceneContainer enabled,
        // because NSSL shouldn't control its own scrolling.
        if (SceneContainerFlag.isEnabled()) {
            return 0;
        }
        // In current design, it only use the top HUN to treat all of HUNs
        // although there are more than one HUNs
        int contentHeight = getContentHeight();
        if (!isExpanded() && mInHeadsUpPinnedMode) {
            contentHeight = mHeadsUpInset + getTopHeadsUpPinnedHeight();
        }
        int scrollRange = Math.max(0, contentHeight - mMaxLayoutHeight);
        int imeInset = getImeInset();
        scrollRange += Math.min(imeInset, Math.max(0, contentHeight - (getHeight() - imeInset)));
        if (scrollRange > 0) {
            scrollRange = Math.max(getScrollAmountToScrollBoundary(), scrollRange);
        }
        return scrollRange;
    }

    private int getImeInset() {
        SceneContainerFlag.assertInLegacyMode();
        // The NotificationStackScrollLayout does not extend all the way to the bottom of the
        // display. Therefore, subtract that space from the mImeInset, in order to only include
        // the portion of the bottom inset that actually overlaps the NotificationStackScrollLayout.
        return Math.max(0, mImeInset
                - (getRootView().getHeight() - getHeight() - getLocationOnScreen()[1]));
    }

    /**
     * @return the first child which has visibility unequal to GONE
     */
    public ExpandableView getFirstChildNotGone() {
        SceneContainerFlag.assertInLegacyMode();
        return getFirstChildNotGoneInternal();
    }

    private ExpandableView getFirstChildNotGoneInternal() {
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() != View.GONE && child != mShelf) {
                return (ExpandableView) child;
            }
        }
        return null;
    }

    /**
     * @return The first child which has visibility unequal to GONE which is currently below the
     * given translationY or equal to it.
     */
    private View getFirstChildBelowTranlsationY(float translationY, boolean ignoreChildren) {
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == View.GONE) {
                continue;
            }
            float rowTranslation = child.getTranslationY();
            if (rowTranslation >= translationY) {
                return child;
            } else if (!ignoreChildren && child instanceof ExpandableNotificationRow row) {
                if (row.isSummaryWithChildren() && row.areChildrenExpanded()) {
                    List<ExpandableNotificationRow> notificationChildren =
                            row.getAttachedChildren();
                    int childrenSize = notificationChildren.size();
                    for (int childIndex = 0; childIndex < childrenSize; childIndex++) {
                        ExpandableNotificationRow rowChild = notificationChildren.get(childIndex);
                        if (rowChild.getTranslationY() + rowTranslation >= translationY) {
                            return rowChild;
                        }
                    }
                }
            }
        }
        return null;
    }

    /**
     * @return the last child which has visibility unequal to GONE
     */
    public ExpandableView getLastChildNotGone() {
        int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            View child = getChildAt(i);
            if (child.getVisibility() != View.GONE && child != mShelf) {
                return (ExpandableView) child;
            }
        }
        return null;
    }

    /**
     * @return the number of children which have visibility unequal to GONE
     */
    public int getNotGoneChildCount() {
        SceneContainerFlag.assertInLegacyMode();
        int childCount = getChildCount();
        int count = 0;
        for (int i = 0; i < childCount; i++) {
            ExpandableView child = getChildAtIndex(i);
            if (child.getVisibility() != View.GONE && !child.willBeGone() && child != mShelf) {
                count++;
            }
        }
        return count;
    }

    @VisibleForTesting
    void updateIntrinsicStackHeight() {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;

        final int shelfIntrinsicHeight = mShelf != null ? mShelf.getIntrinsicHeight() : 0;
        final int notificationsHeight = (int) mNotificationStackSizeCalculator.computeHeight(
                /* notificationStackScrollLayout= */ this,
                mMaxDisplayedNotifications,
                shelfIntrinsicHeight,
                "updateIntrinsicStackHeight"
        );
        if (mScrollViewFields.intrinsicStackHeight != notificationsHeight) {
            mScrollViewFields.intrinsicStackHeight = notificationsHeight;
            notifyStackHeightChangedListeners();
        }
    }

    private void updateContentHeight() {
        if (SceneContainerFlag.isEnabled()) {
            updateIntrinsicStackHeight();
            updateStackEndHeightAndStackHeight(mAmbientState.getExpansionFraction());
            return;
        }

        final float scrimTopPadding = getScrimTopPaddingOrZero();
        final int shelfIntrinsicHeight = mShelf != null ? mShelf.getIntrinsicHeight() : 0;
        final float height =
                (int) scrimTopPadding + (int) mNotificationStackSizeCalculator.computeHeight(
                        /* notificationStackScrollLayout= */ this, mMaxDisplayedNotifications,
                        shelfIntrinsicHeight, "updateContentHeight");
        setIntrinsicContentHeight(height);

        // The topPadding can be bigger than the regular padding when qs is expanded, in that
        // state the maxPanelHeight and the contentHeight should be bigger
        setContentHeight(
                (int) (height + Math.max(getIntrinsicPadding(), getTopPadding()) + mBottomPadding));
        updateScrollability();
        clampScrollPosition();
        updateStackPosition();
        mAmbientState.setContentHeight(getContentHeight());
    }

    @Override
    public int getIntrinsicStackHeight() {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return 0;
        return mScrollViewFields.intrinsicStackHeight;
    }

    @Override
    public int getTopHeadsUpHeight() {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return 0;
        return getTopHeadsUpIntrinsicHeight();
    }

    @Override
    public int getStackBottomInset() {
        return mPaddingBetweenElements + mShelf.getIntrinsicHeight();
    }

    /**
     * Calculate the gap height between two different views
     *
     * @param previous     the previousView
     * @param current      the currentView
     * @param visibleIndex the visible index in the list
     * @return the gap height needed before the current view
     */
    public float calculateGapHeight(
            ExpandableView previous,
            ExpandableView current,
            int visibleIndex
    ) {
        return mStackScrollAlgorithm.getGapHeightForChild(mSectionsManager, visibleIndex, current,
                previous, mAmbientState.getFractionToShade(), mAmbientState.isOnKeyguard());
    }

    public boolean hasPulsingNotifications() {
        return mPulsing;
    }

    private void updateScrollability() {
        if (SceneContainerFlag.isEnabled()) {
            return;
        }
        boolean scrollable = !mQsFullScreen && getScrollRange() > 0;
        if (scrollable != mScrollable) {
            mScrollable = scrollable;
            setFocusable(scrollable);
            updateForwardAndBackwardScrollability();
        }
    }

    private void updateForwardAndBackwardScrollability() {
        SceneContainerFlag.assertInLegacyMode();
        boolean forwardScrollable = mScrollable && !mScrollAdapter.isScrolledToBottom();
        boolean backwardsScrollable = mScrollable && !mScrollAdapter.isScrolledToTop();
        boolean changed = forwardScrollable != mForwardScrollable
                || backwardsScrollable != mBackwardScrollable;
        mForwardScrollable = forwardScrollable;
        mBackwardScrollable = backwardsScrollable;
        if (changed) {
            sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
        }
    }

    private NotificationSection getFirstVisibleSection() {
        for (NotificationSection section : mSections) {
            if (section.getFirstVisibleChild() != null) {
                return section;
            }
        }
        return null;
    }

    private NotificationSection getLastVisibleSection() {
        for (int i = mSections.length - 1; i >= 0; i--) {
            NotificationSection section = mSections[i];
            if (section.getLastVisibleChild() != null) {
                return section;
            }
        }
        return null;
    }

    private ExpandableView getLastChildWithBackground() {
        int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            ExpandableView child = getChildAtIndex(i);
            if (child.getVisibility() != View.GONE && !(child instanceof StackScrollerDecorView)
                    && child != mShelf) {
                return child;
            }
        }
        return null;
    }

    private ExpandableView getFirstChildWithBackground() {
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            ExpandableView child = getChildAtIndex(i);
            if (child.getVisibility() != View.GONE && !(child instanceof StackScrollerDecorView)
                    && child != mShelf) {
                return child;
            }
        }
        return null;
    }

    //TODO: We shouldn't have to generate this list every time
    private List<ExpandableView> getChildrenWithBackground() {
        ArrayList<ExpandableView> children = new ArrayList<>();
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            ExpandableView child = getChildAtIndex(i);
            if (child.getVisibility() != View.GONE
                    && !(child instanceof StackScrollerDecorView)
                    && child != mShelf) {
                children.add(child);
            }
        }
        return children;
    }

    /**
     * Fling the scroll view
     *
     * @param velocityY The initial velocity in the Y direction. Positive
     *                  numbers mean that the finger/cursor is moving down the screen,
     *                  which means we want to scroll towards the top.
     */
    protected void fling(int velocityY) {
        // Scrolls and flings are handled by the Composables with SceneContainer enabled
        SceneContainerFlag.assertInLegacyMode();
        if (getChildCount() > 0) {
            float topAmount = getCurrentOverScrollAmount(true);
            float bottomAmount = getCurrentOverScrollAmount(false);
            if (velocityY < 0 && topAmount > 0) {
                setOwnScrollY(getOwnScrollY() - (int) topAmount);
                if (!mShouldUseSplitNotificationShade) {
                    mDontReportNextOverScroll = true;
                    setOverScrollAmount(0, true, false);
                }
                mMaxOverScroll = Math.abs(velocityY) / 1000f * getRubberBandFactor(true /* onTop */)
                        * mOverflingDistance + topAmount;
            } else if (velocityY > 0 && bottomAmount > 0) {
                setOwnScrollY((int) (getOwnScrollY() + bottomAmount));
                setOverScrollAmount(0, false, false);
                mMaxOverScroll = Math.abs(velocityY) / 1000f
                        * getRubberBandFactor(false /* onTop */) * mOverflingDistance
                        + bottomAmount;
            } else {
                // it will be set once we reach the boundary
                mMaxOverScroll = 0.0f;
            }
            int scrollRange = getScrollRange();
            int minScrollY = Math.max(0, scrollRange);
            if (mExpandedInThisMotion) {
                minScrollY = Math.min(minScrollY, mMaxScrollAfterExpand);
            }
            mScroller.fling(mScrollX, getOwnScrollY(), 1, velocityY, 0, 0, 0, minScrollY, 0,
                    mExpandedInThisMotion && getOwnScrollY() >= 0 ? 0 : Integer.MAX_VALUE / 2);

            animateScroll();
        }
    }

    /**
     * @return Whether a fling performed on the top overscroll edge lead to the expanded
     * overScroll view (i.e QS).
     */
    private boolean shouldOverScrollFling(int initialVelocity) {
        float topOverScroll = getCurrentOverScrollAmount(true);
        return mScrolledToTopOnFirstDown
                && !mExpandedInThisMotion
                && !mShouldUseSplitNotificationShade
                && (initialVelocity > mMinimumVelocity
                || (topOverScroll > mMinTopOverScrollToEscape && initialVelocity > 0));
    }

    /**
     * Updates the top padding of the notifications, taking {@link #getIntrinsicPadding()} into
     * account.
     *
     * @param qsHeight the top padding imposed by the quick settings panel
     * @param animate  whether to animate the change
     */
    public void updateTopPadding(float qsHeight, boolean animate) {
        SceneContainerFlag.assertInLegacyMode();
        int topPadding = (int) qsHeight;
        int minStackHeight = getLayoutMinHeightInternal();
        if (topPadding + minStackHeight > getHeight()) {
            mTopPaddingOverflow = topPadding + minStackHeight - getHeight();
        } else {
            mTopPaddingOverflow = 0;
        }
        if (mAmbientState.getTopPadding() != topPadding) {
            mAmbientState.setTopPadding(topPadding);
            onTopPaddingChanged(/* animate = */ animate && !mKeyguardBypassEnabled);
        }
        setExpandedHeight(mExpandedHeight);
    }

    public int getLayoutMinHeight() {
        SceneContainerFlag.assertInLegacyMode();
        return getLayoutMinHeightInternal();
    }

    private int getLayoutMinHeightInternal() {
        SceneContainerFlag.assertInLegacyMode();
        if (isHeadsUpTransition()) {
            ExpandableNotificationRow trackedHeadsUpRow = mAmbientState.getTrackedHeadsUpRow();
            if (trackedHeadsUpRow.isAboveShelf()) {
                int hunDistance = (int) MathUtils.lerp(
                        0,
                        getPositionInLinearLayout(trackedHeadsUpRow),
                        mAmbientState.getAppearFraction());
                return getTopHeadsUpPinnedHeight() + hunDistance;
            } else {
                return getTopHeadsUpPinnedHeight();
            }
        }
        return mShelf.getVisibility() == GONE ? 0 : mShelf.getIntrinsicHeight();
    }

    public float getTopPaddingOverflow() {
        SceneContainerFlag.assertInLegacyMode();
        return mTopPaddingOverflow;
    }

    private float getRubberBandFactor(boolean onTop) {
        if (!onTop) {
            return RUBBER_BAND_FACTOR_NORMAL;
        }
        if (mExpandedInThisMotion) {
            return RUBBER_BAND_FACTOR_AFTER_EXPAND;
        } else if (mIsExpansionChanging || mPanelTracking) {
            return RUBBER_BAND_FACTOR_ON_PANEL_EXPAND;
        } else if (mScrolledToTopOnFirstDown && !mShouldUseSplitNotificationShade) {
            return 1.0f;
        }
        return RUBBER_BAND_FACTOR_NORMAL;
    }

    /**
     * Accompanying function for {@link #getRubberBandFactor}: Returns true if the overscroll is
     * rubberbanded, false if it is technically an overscroll but rather a motion to expand the
     * overscroll view (e.g. expand QS).
     */
    private boolean isRubberbanded(boolean onTop) {
        return !onTop || mExpandedInThisMotion || mIsExpansionChanging || mPanelTracking
                || !mScrolledToTopOnFirstDown;
    }


    public void setChildTransferInProgress(boolean childTransferInProgress) {
        Assert.isMainThread();
        mChildTransferInProgress = childTransferInProgress;
    }

    @Override
    public void onViewRemoved(View child) {
        super.onViewRemoved(child);
        // we only call our internal methods if this is actually a removal and not just a
        // notification which becomes a child notification
        ExpandableView expandableView = (ExpandableView) child;
        if (!mChildTransferInProgress) {
            if (NotificationMinimalism.isEnabled()
                    && mAmbientState.isOnKeyguard()
                    && this.mOnKeyguardTopLevelNotificationRemovedRunnable != null
                    && expandableView instanceof ExpandableNotificationRow
            ) {
                mOnKeyguardTopLevelNotificationRemovedRunnable.run();
            }
            onViewRemovedInternal(expandableView, this);
        }
        mShelf.requestRoundnessResetFor(expandableView);
    }

    public void cleanUpViewStateForEntry(NotificationEntry entry) {
        View child = entry.getRow();
        if (child == mSwipeHelper.getTranslatingParentView()) {
            mSwipeHelper.clearTranslatingParentView();
        }
    }

    private void onViewRemovedInternal(ExpandableView child, ViewGroup container) {
        if (mChangePositionInProgress) {
            // This is only a position change, don't do anything special
            return;
        }
        child.setOnHeightChangedListener(null);
        if (child instanceof ExpandableNotificationRow row) {
            if (NotificationBundleUi.isEnabled()) {
                row.getEntryAdapter().removeOnSensitivityChangedListener(
                        mOnChildSensitivityChangedListener);
            } else {
                NotificationEntry entry = ((ExpandableNotificationRow) child).getEntryLegacy();
                entry.removeOnSensitivityChangedListener(mOnChildSensitivityChangedListener);
            }
        }
        if (!SceneContainerFlag.isEnabled()) {
            updateScrollStateForRemovedChild(child);
        }
        boolean animationGenerated = container != null && generateRemoveAnimation(child);
        if (animationGenerated) {
            if (!mSwipedOutViews.contains(child) || !isFullySwipedOut(child)) {
                logAddTransientChild(child, container);
                child.addToTransientContainer(container, 0);
            }
        } else {
            mSwipedOutViews.remove(child);

            if (child instanceof ExpandableNotificationRow) {
                ((ExpandableNotificationRow) child).removeChildrenWithKeepInParent();
            }
        }
        updateAnimationState(false, child);

        focusNextViewIfFocused(child);
    }

    private void logAddTransientChild(ExpandableView child, ViewGroup container) {
        if (mLogger == null) {
            return;
        }
        if (child instanceof ExpandableNotificationRow) {
            if (container instanceof NotificationChildrenContainer) {
                mLogger.addTransientChildNotificationToChildContainer(
                        ((ExpandableNotificationRow) child).getLoggingKey(),
                        ((NotificationChildrenContainer) container)
                                .getContainingNotification().getLoggingKey()
                );
            } else if (container instanceof NotificationStackScrollLayout) {
                mLogger.addTransientChildNotificationToNssl(
                        ((ExpandableNotificationRow) child).getLoggingKey()
                );
            } else {
                mLogger.addTransientChildNotificationToViewGroup(
                        ((ExpandableNotificationRow) child).getLoggingKey(),
                        container
                );
            }
        }
    }

    @Override
    public void addTransientView(View view, int index) {
        if (mLogger != null && view instanceof ExpandableNotificationRow) {
            mLogger.addTransientRow(((ExpandableNotificationRow) view).getLoggingKey(), index);
        }
        super.addTransientView(view, index);
    }

    @Override
    public void removeTransientView(View view) {
        if (mLogger != null && view instanceof ExpandableNotificationRow) {
            mLogger.removeTransientRow(((ExpandableNotificationRow) view).getLoggingKey());
        }
        super.removeTransientView(view);
    }

    /**
     * Has this view been fully swiped out such that it's not visible anymore.
     */
    public boolean isFullySwipedOut(ExpandableView child) {
        return Math.abs(child.getTranslation()) >= Math.abs(getTotalTranslationLength(child));
    }

    private void focusNextViewIfFocused(View view) {
        if (view instanceof ExpandableNotificationRow row) {
            if (row.shouldRefocusOnDismiss()) {
                View nextView = row.getChildAfterViewWhenDismissed();
                if (nextView == null) {
                    View groupParentWhenDismissed = row.getGroupParentWhenDismissed();
                    nextView = getFirstChildBelowTranlsationY(groupParentWhenDismissed != null
                            ? groupParentWhenDismissed.getTranslationY()
                            : view.getTranslationY(), true /* ignoreChildren */);
                }
                if (nextView != null) {
                    nextView.requestAccessibilityFocus();
                }
            }
        }

    }

    private boolean isChildInGroup(View child) {
        if (child instanceof ExpandableNotificationRow) {
            ExpandableNotificationRow childRow = (ExpandableNotificationRow) child;
            return NotificationBundleUi.isEnabled()
                    ? mGroupMembershipManager.isChildInGroup(childRow.getEntryAdapter())
                    : mGroupMembershipManager.isChildInGroup(childRow.getEntryLegacy());
        }
        return false;
    }

    /**
     * Generate a remove animation for a child view.
     *
     * @param child The view to generate the remove animation for.
     * @return Whether a new animation was generated or an existing animation was detected by this
     * method. We need this to determine if a transient view is needed.
     */
    boolean generateRemoveAnimation(ExpandableView child) {
        String key = "";
        if (mDebugRemoveAnimation) {
            if (child instanceof ExpandableNotificationRow) {
                key = ((ExpandableNotificationRow) child).getKey();
            }
            Log.d(TAG, "generateRemoveAnimation " + key);
        }
        if (removeRemovedChildFromHeadsUpChangeAnimations(child)) {
            if (mDebugRemoveAnimation) {
                Log.d(TAG, "removedBecauseOfHeadsUp " + key);
            }
            mAddedHeadsUpChildren.remove(child);
            return false;
        }
        // Skip adding animation for clicked heads up notifications when the
        // Shade is closed, because the animation event is generated in
        // generateHeadsUpAnimationEvents. Only report that an animation was
        // actually generated (thus requesting the transient view be added)
        // if a removal animation is in progress.
        if (!isExpanded() && isClickedHeadsUp(child)) {
            // An animation is already running, add it transiently
            mClearTransientViewsWhenFinished.add(child);
            return child.inRemovalAnimation();
        }
        if (mDebugRemoveAnimation) {
            Log.d(TAG, "generateRemove " + key
                    + "\nmIsExpanded " + mIsExpanded
                    + "\nmAnimationsEnabled " + mAnimationsEnabled);
        }
        if (mIsExpanded && mAnimationsEnabled) {
            if (!mChildrenToAddAnimated.contains(child)) {
                if (mDebugRemoveAnimation) {
                    Log.d(TAG, "needsAnimation = true " + key);
                }
                // Generate Animations
                mChildrenToRemoveAnimated.add(child);
                mNeedsAnimation = true;
                return true;
            } else {
                mChildrenToAddAnimated.remove(child);
                mFromMoreCardAdditions.remove(child);
                return false;
            }
        }
        return false;
    }

    private boolean isClickedHeadsUp(View child) {
        return HeadsUpUtil.isClickedHeadsUpNotification(child);
    }

    /**
     * Remove a removed child view from the heads up animations if it was just added there
     *
     * @return whether any child was removed from the list to animate and the view was just added
     */
    private boolean removeRemovedChildFromHeadsUpChangeAnimations(View child) {
        boolean hasAddEvent = false;
        for (HeadsUpAnimationEvent event : mHeadsUpChangeAnimations.values()) {
            ExpandableNotificationRow row = event.getRow();
            boolean isHeadsUp = event.isHeadsUpAppearance();
            if (child == row) {
                mTmpHeadsUpChangeAnimations.add(event.getRow());
                hasAddEvent |= isHeadsUp;
            }
        }
        if (hasAddEvent) {
            // This child was just added lets remove all events.
            mTmpHeadsUpChangeAnimations.forEach((row) -> mHeadsUpChangeAnimations.remove(row));
            ((ExpandableNotificationRow) child).setHeadsUpAnimatingAway(false);
        }
        mTmpHeadsUpChangeAnimations.clear();
        return hasAddEvent && mAddedHeadsUpChildren.contains(child);
    }

    /**
     * Updates the scroll position when a child was removed
     *
     * @param removedChild the removed child
     */
    private void updateScrollStateForRemovedChild(ExpandableView removedChild) {
        SceneContainerFlag.assertInLegacyMode();
        final int startingPosition = getPositionInLinearLayout(removedChild);
        final int childHeight = getIntrinsicHeight(removedChild) + mPaddingBetweenElements;
        final int endPosition = startingPosition + childHeight;
        final int scrollBoundaryStart = getScrollAmountToScrollBoundary();
        mAnimateStackYForContentHeightChange = true;
        // This is reset onLayout
        if (endPosition <= getOwnScrollY() - scrollBoundaryStart) {
            // This child is fully scrolled of the top, so we have to deduct its height from the
            // scrollPosition
            setOwnScrollY(getOwnScrollY() - childHeight);
        } else if (startingPosition < getOwnScrollY() - scrollBoundaryStart) {
            // This child is currently being scrolled into, set the scroll position to the
            // start of this child
            if (NotificationBundleUi.isEnabled()) {
                setOwnScrollY(getOwnScrollY() - childHeight);
            } else {
                setOwnScrollY(startingPosition + scrollBoundaryStart);
            }
        }
    }

    /**
     * @return the amount of scrolling needed to start clipping notifications.
     */
    private int getScrollAmountToScrollBoundary() {
        SceneContainerFlag.assertInLegacyMode();
        if (mShouldUseSplitNotificationShade) {
            return mSidePaddings;
        }
        return getTopPadding() - mQsScrollBoundaryPosition;
    }

    private int getIntrinsicHeight(View view) {
        if (view instanceof ExpandableView expandableView) {
            return expandableView.getIntrinsicHeight();
        }
        return view.getHeight();
    }

    private int getPositionInLinearLayout(View requestedView) {
        ExpandableNotificationRow childInGroup = null;
        ExpandableNotificationRow requestedRow = null;
        if (isChildInGroup(requestedView)) {
            // We're asking for a child in a group. Calculate the position of the parent first,
            // then within the parent.
            childInGroup = (ExpandableNotificationRow) requestedView;
            requestedView = requestedRow = childInGroup.getNotificationParent();
        }
        final float scrimTopPadding = getScrimTopPaddingOrZero();
        int position = (int) scrimTopPadding;
        int visibleIndex = -1;
        ExpandableView lastVisibleChild = null;
        for (int i = 0; i < getChildCount(); i++) {
            ExpandableView child = getChildAtIndex(i);
            boolean notGone = child.getVisibility() != View.GONE;
            if (notGone) visibleIndex++;
            if (notGone && !child.hasNoContentHeight()) {
                if (position != scrimTopPadding) {
                    if (lastVisibleChild != null) {
                        position += calculateGapHeight(lastVisibleChild, child, visibleIndex);
                    }
                    position += mPaddingBetweenElements;
                }
            }
            if (child == requestedView) {
                if (requestedRow != null) {
                    position += requestedRow.getPositionOfChild(childInGroup);
                }
                return position;
            }
            if (notGone) {
                position += getIntrinsicHeight(child);
                lastVisibleChild = child;
            }
        }
        return 0;
    }

    /**
     * Returns the top scrim padding, or zero if the SceneContainer flag is enabled.
     */
    private int getScrimTopPaddingOrZero() {
        if (SceneContainerFlag.isEnabled()) {
            // the scrim padding is set on the notification placeholder
            return 0;
        }
        return mAmbientState.isOnKeyguard() ? 0 : mMinimumPaddings;
    }

    @Override
    public void onViewAdded(View child) {
        super.onViewAdded(child);
        if (child instanceof ExpandableView) {
            onViewAddedInternal((ExpandableView) child);
        }
    }

    private void updateFirstAndLastBackgroundViews() {
        ExpandableView lastChild = getLastChildWithBackground();
        mSectionsManager.updateFirstAndLastViewsForAllSections(
                mSections, getChildrenWithBackground());

        mAmbientState.setLastVisibleBackgroundChild(lastChild);
        invalidate();
    }

    private void onViewAddedInternal(ExpandableView child) {
        updateHideSensitiveForChild(child);
        child.setOnHeightChangedListener(mOnChildHeightChangedListener);
        if (child instanceof ExpandableNotificationRow row) {
            if (NotificationBundleUi.isEnabled()) {
                row.getEntryAdapter().addOnSensitivityChangedListener(
                        mOnChildSensitivityChangedListener);
            } else {
                NotificationEntry entry = ((ExpandableNotificationRow) child).getEntryLegacy();
                entry.addOnSensitivityChangedListener(mOnChildSensitivityChangedListener);
            }
            if (SceneContainerFlag.isEnabled()) {
                row.setOnKeyguard(mIsOnLockscreen);
            }
        }
        generateAddAnimation(child, false /* fromMoreCard */);
        updateAnimationState(child);
        updateChronometerForChild(child);
        if (child instanceof ExpandableNotificationRow row) {
            row.setDismissUsingRowTranslationX(mDismissUsingRowTranslationX, /* force= */ true);
        }
    }

    private void updateHideSensitiveForChild(ExpandableView child) {
        child.setHideSensitiveForIntrinsicHeight(mAmbientState.isHideSensitive());
    }

    public void notifyGroupChildRemoved(ExpandableView row, ViewGroup childrenContainer) {
        onViewRemovedInternal(row, childrenContainer);
    }

    public void notifyGroupChildAdded(ExpandableView row) {
        onViewAddedInternal(row);
    }

    public void setAnimationsEnabled(boolean animationsEnabled) {
        // TODO(b/332732878): remove the initial value of this field once the setter is called
        mAnimationsEnabled = animationsEnabled;
        updateNotificationAnimationStates();
        if (!animationsEnabled) {
            mSwipedOutViews.clear();
            mChildrenToRemoveAnimated.clear();
            clearTemporaryViewsInGroup(
                    /* viewGroup = */ this,
                    /* reason = */ "setAnimationsEnabled");
        }
    }

    private void updateNotificationAnimationStates() {
        boolean running = mAnimationsEnabled || hasPulsingNotifications();
        mShelf.setAnimationsEnabled(running);
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);
            running &= mIsExpanded || isPinnedHeadsUp(child);
            updateAnimationState(running, child);
        }
    }

    void updateAnimationState(View child) {
        updateAnimationState((mAnimationsEnabled || hasPulsingNotifications())
                && (mIsExpanded || isPinnedHeadsUp(child)), child);
    }

    void setExpandingNotification(ExpandableNotificationRow row) {
        if (mExpandingNotificationRow != null && row == null) {
            // Let's unset the clip path being set during launch
            mExpandingNotificationRow.setExpandingClipPath(null);
            ExpandableNotificationRow parent = mExpandingNotificationRow.getNotificationParent();
            if (parent != null) {
                parent.setExpandingClipPath(null);
            }
        }
        mExpandingNotificationRow = row;
        updateLaunchedNotificationClipPath();
        requestChildrenUpdate();
    }

    public void applyLaunchAnimationParams(LaunchAnimationParameters params) {
        // Modify the clipping for launching notifications
        mLaunchAnimationParams = params;
        setLaunchingNotification(params != null);
        updateLaunchedNotificationClipPath();
        requestChildrenUpdate();
    }

    private void updateAnimationState(boolean running, View child) {
        if (child instanceof ExpandableNotificationRow row) {
            row.setAnimationRunning(running);
        }
    }

    boolean isAddOrRemoveAnimationPending() {
        return mNeedsAnimation
                && (!mChildrenToAddAnimated.isEmpty() || !mChildrenToRemoveAnimated.isEmpty());
    }

    public void generateAddAnimation(ExpandableView child, boolean fromMoreCard) {
        if (mIsExpanded && mAnimationsEnabled && !mChangePositionInProgress && !isFullyHidden()) {
            // Generate Animations
            mChildrenToAddAnimated.add(child);
            if (fromMoreCard) {
                mFromMoreCardAdditions.add(child);
            }
            mNeedsAnimation = true;
        }
        if (isHeadsUp(child) && mAnimationsEnabled && !mChangePositionInProgress
                && !isFullyHidden()) {
            mAddedHeadsUpChildren.add(child);
            mChildrenToAddAnimated.remove(child);
        }
    }

    public void changeViewPosition(ExpandableView child, int newIndex) {
        Assert.isMainThread();
        if (mChangePositionInProgress) {
            throw new IllegalStateException("Reentrant call to changeViewPosition");
        }

        int currentIndex = indexOfChild(child);

        if (currentIndex == -1) {
            boolean isTransient = child instanceof ExpandableNotificationRow
                    && child.getTransientContainer() != null;
            Log.e(TAG, "Attempting to re-position "
                    + (isTransient ? "transient" : "")
                    + " view {"
                    + child
                    + "}");
            return;
        }

        if (child != null && child.getParent() == this && currentIndex != newIndex) {
            mChangePositionInProgress = true;
            child.setChangingPosition(true);
            removeView(child);
            addView(child, newIndex);
            child.setChangingPosition(false);
            mChangePositionInProgress = false;
            if (mIsExpanded && mAnimationsEnabled && child.getVisibility() != View.GONE) {
                mChildrenChangingPositions.add(child);
                mNeedsAnimation = true;
            }
        }
    }

    private void startAnimationToState() {
        if (mNeedsAnimation) {
            generateAllAnimationEvents();
            mNeedsAnimation = false;
        }
        if (!mAnimationEvents.isEmpty() || isCurrentlyAnimating()) {
            setAnimationRunning(true);
            mStateAnimator.startAnimationForEvents(mAnimationEvents, mGoToFullShadeDelay);
            mAnimationEvents.clear();
            updateViewShadows();
        } else {
            applyCurrentState();
        }
        mGoToFullShadeDelay = 0;
    }

    private void generateAllAnimationEvents() {
        generateHeadsUpAnimationEvents();
        generateChildRemovalEvents();
        generateChildAdditionEvents();
        generatePositionChangeEvents();
        generateTopPaddingEvent();
        generateActivateEvent();
        generateHideSensitiveEvent();
        generateGoToFullShadeEvent();
        generateViewResizeEvent();
        generateGroupExpansionEvent();
        generateAnimateEverythingEvent();
    }

    private void generateHeadsUpAnimationEvents() {
        for (HeadsUpAnimationEvent headsUpEvent : mHeadsUpChangeAnimations.values()) {
            ExpandableNotificationRow row = headsUpEvent.getRow();
            boolean isHeadsUp = headsUpEvent.isHeadsUpAppearance();
            if (isHeadsUp != row.isHeadsUp()) {
                // For cases where we have a heads up showing and appearing again we shouldn't
                // do the animations at all.
                logHunSkippedForUnexpectedState(row, isHeadsUp, row.isHeadsUp());
                continue;
            }
            int type = AnimationEvent.ANIMATION_TYPE_HEADS_UP_OTHER;
            boolean onBottom = false;
            boolean pinnedAndClosed = row.isPinned() && !mIsExpanded;
            boolean performDisappearAnimation = !mIsExpanded
                    // Only animate if we still have pinned heads up, otherwise we just have the
                    // regular collapse animation of the lock screen
                    || (mKeyguardBypassEnabled && onKeyguard()
                    && mInHeadsUpPinnedMode);
            if (performDisappearAnimation && !isHeadsUp) {
                type = row.wasJustClicked()
                        ? AnimationEvent.ANIMATION_TYPE_HEADS_UP_DISAPPEAR_CLICK
                        : AnimationEvent.ANIMATION_TYPE_HEADS_UP_DISAPPEAR;
                if (NotificationHeadsUpCycling.isEnabled()) {
                    if (mStackScrollAlgorithm.isCyclingOut(row, mAmbientState)) {
                        type = AnimationEvent.ANIMATION_TYPE_HEADS_UP_CYCLING_OUT;
                    }
                }
                if (row.isChildInGroup()) {
                    // We can otherwise get stuck in there if it was just isolated
                    row.setHeadsUpAnimatingAway(false);
                    logHunAnimationSkipped(row, "row is child in group");
                    continue;
                }
            } else {
                ExpandableViewState viewState = row.getViewState();
                if (viewState == null) {
                    // A view state was never generated for this view, so we don't need to animate
                    // this. This may happen with notification children.
                    logHunAnimationSkipped(row, "row has no viewState");
                    continue;
                }
                boolean shouldHunAppearFromTheBottom =
                        mStackScrollAlgorithm.shouldHunAppearFromBottom(mAmbientState, viewState);
                if (isHeadsUp && (mAddedHeadsUpChildren.contains(row) || pinnedAndClosed)) {
                    if (pinnedAndClosed || shouldHunAppearFromTheBottom) {
                        // Our custom add animation
                        type = AnimationEvent.ANIMATION_TYPE_HEADS_UP_APPEAR;
                        if (NotificationHeadsUpCycling.isEnabled()) {
                            if (mStackScrollAlgorithm.isCyclingIn(row, mAmbientState)) {
                                type = AnimationEvent.ANIMATION_TYPE_HEADS_UP_CYCLING_IN;
                            }
                        }
                    } else {
                        // Normal add animation
                        type = AnimationEvent.ANIMATION_TYPE_ADD;
                    }
                    onBottom = !pinnedAndClosed;
                }
            }
            AnimationEvent event = new AnimationEvent(row, type);
            event.headsUpFromBottom = onBottom;

            boolean hasStatusBarChip =
                    PromotedNotificationUi.isEnabled() && headsUpEvent.getHasStatusBarChip();
            event.headsUpHasStatusBarChip = hasStatusBarChip;
            // TODO(b/283084712) remove this and update the HUN filters at creation
            event.filter.animateHeight = false;
            mAnimationEvents.add(event);
            if (SPEW) {
                Log.v(TAG, "Generating HUN animation event: "
                        + " isHeadsUp=" + isHeadsUp
                        + " type=" + type
                        + " onBottom=" + onBottom
                        + " row=" + row.getKey());
            }
            logHunAnimationEventAdded(row, type);
        }
        mHeadsUpChangeAnimations.clear();
        mAddedHeadsUpChildren.clear();
    }

    private void generateGroupExpansionEvent() {
        // Generate a group expansion/collapsing event if there is such a group at all
        if (mExpandedGroupView != null) {
            mAnimationEvents.add(new AnimationEvent(mExpandedGroupView,
                    AnimationEvent.ANIMATION_TYPE_GROUP_EXPANSION_CHANGED));
            mExpandedGroupView = null;
        }
    }

    private void generateViewResizeEvent() {
        if (mNeedViewResizeAnimation) {
            boolean hasDisappearAnimation = false;
            for (AnimationEvent animationEvent : mAnimationEvents) {
                final int type = animationEvent.animationType;
                if (type == AnimationEvent.ANIMATION_TYPE_HEADS_UP_DISAPPEAR_CLICK
                        || type == AnimationEvent.ANIMATION_TYPE_HEADS_UP_DISAPPEAR) {
                    hasDisappearAnimation = true;
                    break;
                }
            }

            if (!hasDisappearAnimation) {
                mAnimationEvents.add(
                        new AnimationEvent(null, AnimationEvent.ANIMATION_TYPE_VIEW_RESIZE));
            }
        }
        mNeedViewResizeAnimation = false;
    }

    private void generateChildRemovalEvents() {
        for (ExpandableView child : mChildrenToRemoveAnimated) {
            boolean childWasSwipedOut = mSwipedOutViews.contains(child);

            // we need to know the view after this one
            float removedTranslation = child.getTranslationY();
            boolean ignoreChildren = true;
            if (child instanceof ExpandableNotificationRow row) {
                if (row.isRemoved() && row.wasChildInGroupWhenRemoved()) {
                    removedTranslation = row.getTranslationWhenRemoved();
                    ignoreChildren = false;
                }
                childWasSwipedOut |= isFullySwipedOut(row);
            } else if (child instanceof MediaContainerView) {
                childWasSwipedOut = true;
            }
            if (!childWasSwipedOut) {
                Rect clipBounds = child.getClipBounds();
                childWasSwipedOut = clipBounds != null && clipBounds.height() == 0;

                if (childWasSwipedOut) {
                    // Clean up any potential transient views if the child has already been swiped
                    // out, as we won't be animating it further (due to its height already being
                    // clipped to 0.
                    child.removeFromTransientContainer();
                }
            }
            int animationType = childWasSwipedOut
                    ? AnimationEvent.ANIMATION_TYPE_REMOVE_SWIPED_OUT
                    : AnimationEvent.ANIMATION_TYPE_REMOVE;
            AnimationEvent event = new AnimationEvent(child, animationType);
            event.viewAfterChangingView = getFirstChildBelowTranlsationY(removedTranslation,
                    ignoreChildren);
            mAnimationEvents.add(event);
            mSwipedOutViews.remove(child);
            if (mDebugRemoveAnimation) {
                String key = "";
                if (child instanceof ExpandableNotificationRow) {
                    key = ((ExpandableNotificationRow) child).getKey();
                }
                Log.d(TAG, "created Remove Event - SwipedOut: " + childWasSwipedOut + " " + key);
            }
        }
        mChildrenToRemoveAnimated.clear();
    }

    private void generatePositionChangeEvents() {
        for (ExpandableView child : mChildrenChangingPositions) {
            Integer duration = null;
            if (child instanceof ExpandableNotificationRow row) {
                if (NotificationBundleUi.isEnabled()) {
                    if (row.getEntryAdapter().isMarkedForUserTriggeredMovement()) {
                        duration = StackStateAnimator.ANIMATION_DURATION_PRIORITY_CHANGE;
                        row.getEntryAdapter().markForUserTriggeredMovement(false);
                    }
                } else {
                    if (row.getEntryLegacy().isMarkedForUserTriggeredMovement()) {
                        duration = StackStateAnimator.ANIMATION_DURATION_PRIORITY_CHANGE;
                        row.getEntryLegacy().markForUserTriggeredMovement(false);
                    }
                }
            }
            AnimationEvent animEvent = duration == null
                    ? new AnimationEvent(child, AnimationEvent.ANIMATION_TYPE_CHANGE_POSITION)
                    : new AnimationEvent(
                    child, AnimationEvent.ANIMATION_TYPE_CHANGE_POSITION, duration);
            mAnimationEvents.add(animEvent);
        }
        mChildrenChangingPositions.clear();
    }

    private void generateChildAdditionEvents() {
        for (ExpandableView child : mChildrenToAddAnimated) {
            if (mFromMoreCardAdditions.contains(child)) {
                mAnimationEvents.add(new AnimationEvent(child,
                        AnimationEvent.ANIMATION_TYPE_ADD,
                        StackStateAnimator.ANIMATION_DURATION_STANDARD));
            } else {
                mAnimationEvents.add(new AnimationEvent(child,
                        AnimationEvent.ANIMATION_TYPE_ADD));
            }
        }
        mChildrenToAddAnimated.clear();
        mFromMoreCardAdditions.clear();
    }

    private void generateTopPaddingEvent() {
        if (mTopPaddingNeedsAnimation) {
            AnimationEvent event;
            if (mAmbientState.isDozing()) {
                event = new AnimationEvent(null /* view */,
                        AnimationEvent.ANIMATION_TYPE_TOP_PADDING_CHANGED,
                        KeyguardSliceView.DEFAULT_ANIM_DURATION);
            } else {
                event = new AnimationEvent(null /* view */,
                        AnimationEvent.ANIMATION_TYPE_TOP_PADDING_CHANGED);
            }
            mAnimationEvents.add(event);
        }
        mTopPaddingNeedsAnimation = false;
    }

    private void generateActivateEvent() {
        if (mActivateNeedsAnimation) {
            mAnimationEvents.add(
                    new AnimationEvent(null, AnimationEvent.ANIMATION_TYPE_ACTIVATED_CHILD));
        }
        mActivateNeedsAnimation = false;
    }

    private void generateAnimateEverythingEvent() {
        if (mEverythingNeedsAnimation) {
            mAnimationEvents.add(
                    new AnimationEvent(null, AnimationEvent.ANIMATION_TYPE_EVERYTHING));
        }
        mEverythingNeedsAnimation = false;
    }

    private void generateHideSensitiveEvent() {
        if (mHideSensitiveNeedsAnimation) {
            mAnimationEvents.add(
                    new AnimationEvent(null, AnimationEvent.ANIMATION_TYPE_HIDE_SENSITIVE));
        }
        mHideSensitiveNeedsAnimation = false;
    }

    private void generateGoToFullShadeEvent() {
        if (mGoToFullShadeNeedsAnimation) {
            mAnimationEvents.add(
                    new AnimationEvent(null, AnimationEvent.ANIMATION_TYPE_GO_TO_FULL_SHADE));
        }
        mGoToFullShadeNeedsAnimation = false;
    }

    /**
     * @return Whether a y coordinate is inside the content.
     */
    public boolean isInContentBounds(float y) {
        if (SceneContainerFlag.isEnabled()) {
            return y < mAmbientState.getStackCutoff();
        } else {
            return y < getHeight() - getEmptyBottomMarginInternal();
        }
    }

    private float getTouchSlop(MotionEvent event) {
        // Adjust the touch slop if another gesture may be being performed.
        return event.getClassification() == MotionEvent.CLASSIFICATION_AMBIGUOUS_GESTURE
                ? mTouchSlop * mSlopMultiplier
                : mTouchSlop;
    }

    /**
     * onInterceptTouchEvent is a gatekeeper, decides whether the NSSL want to steal te event from
     * its children. Return true when the NSSL doesn't want its children to get the MotionEvent.
     * It's not guaranteed that the NSSL will react to the touch event when onInterceptTouchEvent
     * returns true, sometimes (eg. refuseTouchEvent), the NSSL intercepts the touch event just
     * to avoid consuming it in itself or descendants.
     *
     * @param ev A MotionEvent that's dispatched to the NSSL
     * @return True when the NSSL wants to prevent its descendants to get the MotionEvent.
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (SceneContainerFlag.isEnabled()) {
            // TODO(b/433984972): this is especially useful because there are scenarios that the
            //  NSSL children overlaps with other visible, interactive items.
            if (shouldRefuseTouchEvent(ev)) {
                return true;
            }
        }

        if (mTouchHandler != null && mTouchHandler.onInterceptTouchEvent(ev)) {
            return true;
        }
        return super.onInterceptTouchEvent(ev);
    }

    /**
     * When an ACTION_DOWN event is outside of the NSSL's draw bounds, that means a gesture started
     * outside of the NSSL's draw bounds. The NSSL will intercept the gesture from its children and
     * refuse it by returning false in NSSL#onTouchEvent.
     *
     * @param ev A MotionEvent
     * @return true when the event is an ACTION_DOWN event that's outside of the NSSL's draw bounds
     */

    private boolean isOutBoundsDownEvent(MotionEvent ev) {
        if (!SceneContainerFlag.isEnabled()) return false;
        final int action = ev.getActionMasked();
        if (action == ACTION_DOWN || action == ACTION_POINTER_DOWN) {
            // Get the index of the pointer that just triggered the event.
            final int pointerIndex = ev.getActionIndex();
            // Get the unique ID for that pointer.
            final int pointerId = ev.getPointerId(pointerIndex);

            // onTouchEvent removes the pointerId
            if (mOutBoundsEventId == pointerId) {
                mOutBoundsEventId = -1;
                return true;
            }

            // Get the coordinates for that pointer.
            final float x = ev.getX(pointerIndex);
            final float y = ev.getY(pointerIndex);

            if (!isInDrawBounds(x, y)) {
                // onInterceptTouchEvent records the pointerId
                mOutBoundsEventId = pointerId;
                return true;
            }
        }

        return false;
    }


    /**
     * For some reason, the NSSL doesn't want to handle this MotionEvent and prevent its children
     * from handling. The NSSL will 'intercept' the MotionEvent and return false in
     * NSSL#onTouchEvent to return early and skip the touch handlers checking on the touch
     * event.
     *
     * @param ev the MotionEvent to test
     * @return true when the NSSL doesn't want this MotionEvent and want to prevent its children
     * from getting the MotionEvent.
     */
    @VisibleForTesting
    boolean shouldRefuseTouchEvent(MotionEvent ev) {
        if (!SceneContainerFlag.isEnabled()) {
            return false;
        }

        return !mScrollViewFields.interactive
                // NSSL refuse gesture if it started outside of the touchable bounds
                || isOutBoundsDownEvent(ev);
    }

    /**
     * Whether position [x, y] is within the draw bounds of the NSSL. Note that
     * NSSL#setDrawBounds() should be called to set the draw bounds before calling this method.
     *
     * @param x x coordinate for the initial touch of the current gesture
     * @param y y coordinate for the initial touch of the current gesture
     * @return Whether the position within the draw bounds of the NSSL.
     */
    private boolean isInDrawBounds(float x, float y) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) {
            return false;
        }
        RectF bounds = mAmbientState.getDrawBounds();
        return bounds.contains(x, y);
    }

    /**
     * @param ev A MotionEvent that's dispatched to the NSSL.
     * @return True when the NSSL actuall consumes the MotionEvent.
     */
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (SceneContainerFlag.isEnabled()) {
            if (shouldRefuseTouchEvent(ev)) {
                return false;
            }
        }

        if (mTouchHandler != null) {
            boolean touchHandled = mTouchHandler.onTouchEvent(ev);
            if (SceneContainerFlag.isEnabled() || touchHandled) {
                // If scene container is enabled, let the TouchHandlers decide if this event is
                // handled by the NSSL.
                return touchHandled;
            }
        }

        return super.onTouchEvent(ev);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (SceneContainerFlag.isEnabled()) {
            int action = ev.getActionMasked();
            boolean isTouchInGuts = mController.isTouchInGutsView(ev);
            if (action == ACTION_DOWN && !isTouchInGuts) {
                mController.closeControlsDueToOutsideTouch();
            }
            if (shouldDispatchToSceneContainer()) {
                // Dispatch TouchEvent to the scene framework
                boolean isUpOrCancel = action == ACTION_UP || action == ACTION_CANCEL;
                if (mSendingTouchesToSceneFramework) {
                    MotionEvent adjustedEvent = MotionEvent.obtain(ev);
                    adjustedEvent.setLocation(ev.getRawX(), ev.getRawY());
                    mScrollViewFields.sendCurrentGestureExpandingNotification(
                            mExpandingNotification && !isUpOrCancel);
                    mController.sendTouchToSceneFramework(adjustedEvent);
                    adjustedEvent.recycle();
                } else if (!isUpOrCancel) {
                    // if this is the first touch being sent to the scene framework,
                    // convert it into a synthetic DOWN event.
                    mSendingTouchesToSceneFramework = true;
                    MotionEvent downEvent = MotionEvent.obtain(ev);
                    downEvent.setAction(ACTION_DOWN);
                    downEvent.setLocation(ev.getRawX(), ev.getRawY());
                    mScrollViewFields.sendCurrentGestureInGuts(isTouchInGuts);
                    mScrollViewFields.sendCurrentGestureExpandingNotification(
                            mExpandingNotification);
                    mController.sendTouchToSceneFramework(downEvent);
                    downEvent.recycle();
                }

                if (isUpOrCancel) {
                    mScrollViewFields.sendCurrentGestureInGuts(false);
                    mScrollViewFields.sendCurrentGestureExpandingNotification(false);
                    setIsBeingDragged(false);
                }
            }
        }
        // dispatch all touches to TouchHandlers, so they can decide whether they want to handle it.
        return TouchLogger.logDispatchTouch(TAG, ev, super.dispatchTouchEvent(ev));
    }

    void dispatchDownEventToScroller(MotionEvent ev) {
        MotionEvent downEvent = MotionEvent.obtain(ev);
        downEvent.setAction(ACTION_DOWN);
        onScrollTouch(downEvent);
        downEvent.recycle();
    }

    // Only when scene container is enabled, mark that we are being dragged so that we start
    // dispatching the rest of the gesture to scene container.
    void startDraggingOnHun() {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        setIsBeingDragged(true);
    }

    /**
     * @return Whether NSSL should dispatch this event to the SceneContainer Framework. When false,
     * the NSSL will handle the event itself.
     */
    private boolean shouldDispatchToSceneContainer() {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) {
            return false;
        }
        return mIsBeingDragged || mExpandingNotification;
    }

    @Override
    public boolean onGenericMotionEvent(MotionEvent event) {
        if (!isScrollingEnabled()
                || !mIsExpanded
                || mSwipeHelper.isSwiping()
                || mExpandingNotification
                || mDisallowScrollingInThisMotion) {
            return false;
        }
        if ((event.getSource() & InputDevice.SOURCE_CLASS_POINTER) != 0) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_SCROLL: {
                    // If scene container is active, NSSL should not control its own scrolling.
                    if (SceneContainerFlag.isEnabled()) {
                        return false;
                    }
                    if (!mIsBeingDragged) {
                        final float vscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
                        if (vscroll != 0) {
                            final int delta = (int) (vscroll * getVerticalScrollFactor());
                            final int range = getScrollRange();
                            int oldScrollY = getOwnScrollY();
                            int newScrollY = oldScrollY - delta;
                            if (newScrollY < 0) {
                                newScrollY = 0;
                            } else if (newScrollY > range) {
                                newScrollY = range;
                            }
                            if (newScrollY != oldScrollY) {
                                setOwnScrollY(newScrollY);
                                return true;
                            }
                        }
                    }
                }
            }
        }
        return super.onGenericMotionEvent(event);
    }

    boolean onScrollTouch(MotionEvent ev) {
        if (!isScrollingEnabled()) {
            return false;
        }
        if (!isInScrollableRegion(ev) && !mIsBeingDragged) {
            return false;
        }
        mForcedScroll = null;
        initVelocityTrackerIfNotExists();
        mVelocityTracker.addMovement(ev);

        final int action = ev.getActionMasked();
        if (ev.findPointerIndex(mActivePointerId) == -1 && action != ACTION_DOWN) {
            // Incomplete gesture, possibly due to window swap mid-gesture. Ignore until a new
            // one starts.
            Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent "
                    + MotionEvent.actionToString(ev.getActionMasked()));
            return true;
        }

        // If the scene framework is enabled, ignore all non-move gestures if we are currently
        // dragging - they should be dispatched to the scene framework. Move gestures should be let
        // through to determine if we are still dragging or not.
        if (
                SceneContainerFlag.isEnabled()
                && mIsBeingDragged
                && action != MotionEvent.ACTION_MOVE
        ) {
            setIsBeingDragged(false);
            return false;
        }

        switch (action) {
            case ACTION_DOWN: {
                if (getChildCount() == 0 || !isInContentBounds(ev)) {
                    return false;
                }
                boolean isBeingDragged = !mScroller.isFinished();
                setIsBeingDragged(isBeingDragged);
                /*
                 * If being flinged and user touches, stop the fling. isFinished
                 * will be false if being flinged.
                 */
                if (!mScroller.isFinished()) {
                    mScroller.forceFinished(true);
                }

                // Remember where the motion event started
                mLastMotionY = (int) ev.getY();
                mDownX = (int) ev.getX();
                mActivePointerId = ev.getPointerId(0);
                break;
            }
            case MotionEvent.ACTION_MOVE:
                final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
                if (activePointerIndex == -1) {
                    Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
                    break;
                }

                final int y = (int) ev.getY(activePointerIndex);
                final int x = (int) ev.getX(activePointerIndex);
                int deltaY = mLastMotionY - y;
                final int xDiff = Math.abs(x - mDownX);
                final int yDiff = Math.abs(deltaY);
                final float touchSlop = getTouchSlop(ev);
                if (!mIsBeingDragged && yDiff > touchSlop && yDiff > xDiff) {
                    setIsBeingDragged(true);
                    if (deltaY > 0) {
                        deltaY -= touchSlop;
                    } else {
                        deltaY += touchSlop;
                    }
                }
                if (mIsBeingDragged) {
                    // Defer actual scrolling to the scene framework if enabled
                    if (SceneContainerFlag.isEnabled()) {
                        return false;
                    }
                    // Scroll to follow the motion event
                    mLastMotionY = y;
                    float scrollAmount;
                    int range;
                    range = getScrollRange();
                    if (mExpandedInThisMotion) {
                        range = Math.min(range, mMaxScrollAfterExpand);
                    }
                    if (deltaY < 0) {
                        scrollAmount = overScrollDown(deltaY);
                    } else {
                        scrollAmount = overScrollUp(deltaY, range);
                    }

                    // Calling customOverScrollBy will call onCustomOverScrolled, which
                    // sets the scrolling if applicable.
                    if (scrollAmount != 0.0f) {
                        // The scrolling motion could not be compensated with the
                        // existing overScroll, we have to scroll the view
                        customOverScrollBy((int) scrollAmount, getOwnScrollY(),
                                range, getHeight() / 2);
                    }
                }
                break;
            case ACTION_UP:
                if (SceneContainerFlag.isEnabled() && mIsBeingDragged) {
                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                } else if (mIsBeingDragged) {
                    final VelocityTracker velocityTracker = mVelocityTracker;
                    velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                    int initialVelocity = (int) velocityTracker.getYVelocity(mActivePointerId);

                    if (shouldOverScrollFling(initialVelocity)) {
                        onOverScrollFling(true, initialVelocity);
                    } else {
                        if (getChildCount() > 0) {
                            if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
                                float currentOverScrollTop = getCurrentOverScrollAmount(true);
                                if (currentOverScrollTop == 0.0f || initialVelocity > 0) {
                                    mFlingAfterUpEvent = true;
                                    setFinishScrollingCallback(() -> {
                                        mFlingAfterUpEvent = false;
                                        InteractionJankMonitor.getInstance()
                                                .end(CUJ_NOTIFICATION_SHADE_SCROLL_FLING);
                                        setFinishScrollingCallback(null);
                                    });
                                    fling(-initialVelocity);
                                } else {
                                    onOverScrollFling(false, initialVelocity);
                                }
                            } else {
                                if (mScroller.springBack(mScrollX, getOwnScrollY(), 0, 0, 0,
                                        getScrollRange())) {
                                    animateScroll();
                                }
                            }
                        }
                    }
                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                }

                break;
            case ACTION_CANCEL:
                if (mIsBeingDragged && getChildCount() > 0) {
                    if (mScroller.springBack(mScrollX, getOwnScrollY(), 0, 0, 0,
                            getScrollRange())) {
                        animateScroll();
                    }
                    mActivePointerId = INVALID_POINTER;
                    endDrag();
                }
                break;
            case ACTION_POINTER_DOWN: {
                final int index = ev.getActionIndex();
                mLastMotionY = (int) ev.getY(index);
                mDownX = (int) ev.getX(index);
                mActivePointerId = ev.getPointerId(index);
                break;
            }
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                mLastMotionY = (int) ev.getY(ev.findPointerIndex(mActivePointerId));
                mDownX = (int) ev.getX(ev.findPointerIndex(mActivePointerId));
                break;
        }
        return true;
    }

    boolean isFlingAfterUpEvent() {
        SceneContainerFlag.assertInLegacyMode();
        return mFlingAfterUpEvent;
    }

    /** Is this touch event inside the scrollable region? */
    @VisibleForTesting
    boolean isInScrollableRegion(MotionEvent ev) {
        if (!SceneContainerFlag.isEnabled()) {
            return !isInsideQsHeader(ev);
        }
        ShadeScrimShape shape = mScrollViewFields.clippingShape;
        if (shape == null) {
            return true; // When there is no scrim, consider this event scrollable.
        }

        ShadeScrimBounds bounds = shape.getBounds();
        return ev.getX() >= bounds.getLeft()
                && ev.getX() <= bounds.getRight()
                && ev.getY() >= bounds.getTop()
                && ev.getY() <= bounds.getBottom();
    }

    protected boolean isInsideQsHeader(MotionEvent ev) {
        SceneContainerFlag.assertInLegacyMode();
        if (QSComposeFragment.isEnabled()) {
            if (mQSHeaderBoundsProvider == null) {
                return false;
            } else {
                mQSHeaderBoundsProvider.getBoundsOnScreenProvider().invoke(mQsHeaderBound);
            }
        } else {
            if (mQsHeader == null) {
                return false;
            } else {
                mQsHeader.getBoundsOnScreen(mQsHeaderBound);
            }
        }

        /**
         * One-handed mode defines a feature FEATURE_ONE_HANDED of DisplayArea {@link DisplayArea}
         * that will translate down the Y-coordinate whole window screen type except for
         * TYPE_NAVIGATION_BAR and TYPE_NAVIGATION_BAR_PANEL .{@link DisplayAreaPolicy}.
         *
         * So, to consider triggered One-handed mode would translate down the absolute Y-coordinate
         * of DisplayArea into relative coordinates for all windows, we need to correct the
         * QS Head bounds here.
         */
        int left =
                QSComposeFragment.isEnabled() ? mQSHeaderBoundsProvider.getLeftProvider().invoke()
                        : mQsHeader.getLeft();
        final int xOffset = Math.round(ev.getRawX() - ev.getX() + left);
        final int yOffset = Math.round(ev.getRawY() - ev.getY());
        mQsHeaderBound.offsetTo(xOffset, yOffset);
        return mQsHeaderBound.contains((int) ev.getRawX(), (int) ev.getRawY());
    }

    private void onOverScrollFling(boolean open, int initialVelocity) {
        if (mOverscrollTopChangedListener != null) {
            mOverscrollTopChangedListener.flingTopOverscroll(initialVelocity, open);
        }
        mDontReportNextOverScroll = true;
        setOverScrollAmount(0.0f, true, false);
    }


    private void onSecondaryPointerUp(MotionEvent ev) {
        final int pointerIndex = (ev.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >>
                MotionEvent.ACTION_POINTER_INDEX_SHIFT;
        final int pointerId = ev.getPointerId(pointerIndex);
        if (pointerId == mActivePointerId) {
            // This was our active pointer going up. Choose a new
            // active pointer and adjust accordingly.
            // TODO: Make this decision more intelligent.
            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
            mLastMotionY = (int) ev.getY(newPointerIndex);
            mActivePointerId = ev.getPointerId(newPointerIndex);
            if (mVelocityTracker != null) {
                mVelocityTracker.clear();
            }
        }
    }

    private void endDrag() {
        setIsBeingDragged(false);

        recycleVelocityTracker();

        if (getCurrentOverScrollAmount(true /* onTop */) > 0) {
            setOverScrollAmount(0, true /* onTop */, true /* animate */);
        }
        if (getCurrentOverScrollAmount(false /* onTop */) > 0) {
            setOverScrollAmount(0, false /* onTop */, true /* animate */);
        }
    }

    void handleEmptySpaceClick(MotionEvent ev) {
        if (SceneContainerFlag.isEnabled()) return;
        logEmptySpaceClick(ev, isBelowLastNotification(mInitialTouchX, mInitialTouchY),
                mStatusBarState, mTouchIsClick);
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_MOVE:
                final float touchSlop = getTouchSlop(ev);
                if (mTouchIsClick && (Math.abs(ev.getY() - mInitialTouchY) > touchSlop
                        || Math.abs(ev.getX() - mInitialTouchX) > touchSlop)) {
                    mTouchIsClick = false;
                }
                break;
            case ACTION_UP:
                if (mStatusBarState != StatusBarState.KEYGUARD && mTouchIsClick &&
                        isBelowLastNotification(mInitialTouchX, mInitialTouchY)) {
                    debugShadeLog("handleEmptySpaceClick: touch event propagated further");
                    mOnEmptySpaceClickListener.onEmptySpaceClicked(mInitialTouchX, mInitialTouchY);
                }
                break;
            default:
                debugShadeLog("handleEmptySpaceClick: MotionEvent ignored");
        }
    }

    private void debugShadeLog(@CompileTimeConstant final String s) {
        if (mLogger == null) {
            return;
        }
        mLogger.logShadeDebugEvent(s);
    }

    private void logEmptySpaceClick(MotionEvent ev, boolean isTouchBelowLastNotification,
                                    int statusBarState, boolean touchIsClick) {
        if (mLogger == null) {
            return;
        }
        mLogger.logEmptySpaceClick(
                isTouchBelowLastNotification,
                statusBarState,
                touchIsClick,
                MotionEvent.actionToString(ev.getActionMasked()));
    }

    void initDownStates(MotionEvent ev) {
        if (ev.getAction() == ACTION_DOWN) {
            mExpandedInThisMotion = false;
            mOnlyScrollingInThisMotion = !mScroller.isFinished();
            mDisallowScrollingInThisMotion = false;
            mDisallowDismissInThisMotion = false;
            mTouchIsClick = true;
            mInitialTouchX = ev.getX();
            mInitialTouchY = ev.getY();
        }
    }

    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        super.requestDisallowInterceptTouchEvent(disallowIntercept);
        if (disallowIntercept) {
            cancelLongPress();
        }
    }

    boolean onInterceptTouchEventScroll(MotionEvent ev) {
        if (!isScrollingEnabled()) {
            return false;
        }
        /*
         * This method JUST determines whether we want to intercept the motion.
         * If we return true, onMotionEvent will be called and we do the actual
         * scrolling there.
         */

        /*
         * Shortcut the most recurring case: the user is in the dragging
         * state and is moving their finger.  We want to intercept this
         * motion.
         */
        final int action = ev.getAction();
        if ((action == MotionEvent.ACTION_MOVE) && (mIsBeingDragged)) {
            return true;
        }

        switch (action & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_MOVE: {
                /*
                 * mIsBeingDragged == false, otherwise the shortcut would have caught it. Check
                 * whether the user has moved far enough from the original down touch.
                 */

                /*
                 * Locally do absolute value. mLastMotionY is set to the y value
                 * of the down event.
                 */
                final int activePointerId = mActivePointerId;
                if (activePointerId == INVALID_POINTER) {
                    // If we don't have a valid id, the touch down wasn't on content.
                    break;
                }

                final int pointerIndex = ev.findPointerIndex(activePointerId);
                if (pointerIndex == -1) {
                    Log.e(TAG, "Invalid pointerId=" + activePointerId
                            + " in onInterceptTouchEvent");
                    break;
                }

                final int y = (int) ev.getY(pointerIndex);
                final int x = (int) ev.getX(pointerIndex);
                final int yDiff = Math.abs(y - mLastMotionY);
                final int xDiff = Math.abs(x - mDownX);
                if (yDiff > getTouchSlop(ev) && yDiff > xDiff) {
                    setIsBeingDragged(true);
                    mLastMotionY = y;
                    mDownX = x;
                    initVelocityTrackerIfNotExists();
                    mVelocityTracker.addMovement(ev);
                }
                break;
            }

            case ACTION_DOWN: {
                final int y = (int) ev.getY();
                mScrolledToTopOnFirstDown = mScrollAdapter.isScrolledToTop();
                final ExpandableView childAtTouchPos = getChildAtPosition(
                        ev.getX(), y, false /* requireMinHeight */,
                        false /* ignoreDecors */, true /* ignoreWidth */);
                if (childAtTouchPos == null) {
                    setIsBeingDragged(false);
                    recycleVelocityTracker();
                    break;
                }

                /*
                 * Remember location of down touch.
                 * ACTION_DOWN always refers to pointer index 0.
                 */
                mLastMotionY = y;
                mDownX = (int) ev.getX();
                mActivePointerId = ev.getPointerId(0);

                initOrResetVelocityTracker();
                mVelocityTracker.addMovement(ev);
                /*
                 * If being flinged and user touches the screen, initiate drag;
                 * otherwise don't.  mScroller.isFinished should be false when
                 * being flinged.
                 */
                boolean isBeingDragged = !mScroller.isFinished();
                setIsBeingDragged(isBeingDragged);
                break;
            }

            case ACTION_CANCEL:
            case ACTION_UP:
                /* Release the drag */
                setIsBeingDragged(false);
                mActivePointerId = INVALID_POINTER;
                recycleVelocityTracker();
                if (mScroller.springBack(mScrollX, getOwnScrollY(), 0, 0, 0, getScrollRange())) {
                    animateScroll();
                }
                break;
            case MotionEvent.ACTION_POINTER_UP:
                onSecondaryPointerUp(ev);
                break;
        }

        /*
         * The only time we want to intercept motion events is if we are in the
         * drag mode.
         */
        return mIsBeingDragged;
    }

    /**
     * @return Whether the specified motion event is actually happening over the content.
     */
    private boolean isInContentBounds(MotionEvent event) {
        return isInContentBounds(event.getY());
    }


    @VisibleForTesting
    void setIsBeingDragged(boolean isDragged) {
        mIsBeingDragged = isDragged;
        if (isDragged) {
            requestDisallowInterceptTouchEvent(true);
            cancelLongPress();
            resetExposedMenuView(true /* animate */, true /* force */);
        } else {
            mSendingTouchesToSceneFramework = false;
        }
    }

    @VisibleForTesting
    boolean getIsBeingDragged() {
        return mIsBeingDragged;
    }

    public void requestDisallowLongPress() {
        cancelLongPress();
    }

    public void requestDisallowDismiss() {
        mDisallowDismissInThisMotion = true;
    }

    public void cancelLongPress() {
        mSwipeHelper.cancelLongPress();
    }

    public void setOnEmptySpaceClickListener(OnEmptySpaceClickListener listener) {
        SceneContainerFlag.assertInLegacyMode();
        mOnEmptySpaceClickListener = listener;
    }

    /**
     * @hide
     */
    @Override
    public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
        if (super.performAccessibilityActionInternal(action, arguments)) {
            return true;
        }
        if (!isEnabled()) {
            return false;
        }

        if (SceneContainerFlag.isEnabled()) {
            switch (action) {
                case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
                case android.R.id.accessibilityActionScrollDown:
                    mScrollViewFields.sendAccessibilityScrollEvent(SCROLL_DOWN);
                    return true;
                case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
                case android.R.id.accessibilityActionScrollUp:
                    mScrollViewFields.sendAccessibilityScrollEvent(SCROLL_UP);
                    return true;
            }
            return false;
        }

        int direction = -1;
        switch (action) {
            case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
                // fall through
            case android.R.id.accessibilityActionScrollDown:
                direction = 1;
                // fall through
            case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
                // fall through
            case android.R.id.accessibilityActionScrollUp:
                final int viewportHeight =
                        getHeight() - mPaddingBottom - getTopPadding() - mPaddingTop
                                - mShelf.getIntrinsicHeight();
                final int targetScrollY = Math.max(0,
                        Math.min(getOwnScrollY() + direction * viewportHeight, getScrollRange()));
                if (targetScrollY != getOwnScrollY()) {
                    mScroller.startScroll(mScrollX, getOwnScrollY(), 0,
                            targetScrollY - getOwnScrollY());
                    animateScroll();
                    return true;
                }
                break;
        }
        return false;
    }

    @Override
    public void onWindowFocusChanged(boolean hasWindowFocus) {
        super.onWindowFocusChanged(hasWindowFocus);
        if (!hasWindowFocus) {
            cancelLongPress();
        }
    }

    @Override
    public void clearChildFocus(View child) {
        super.clearChildFocus(child);
        if (mForcedScroll == child) {
            mForcedScroll = null;
        }
    }

    boolean isScrolledToBottom() {
        SceneContainerFlag.assertInLegacyMode();
        return mScrollAdapter.isScrolledToBottom();
    }

    int getEmptyBottomMargin() {
        SceneContainerFlag.assertInLegacyMode();
        return getEmptyBottomMarginInternal();
    }

    private int getEmptyBottomMarginInternal() {
        int contentHeight;
        if (mShouldUseSplitNotificationShade) {
            // When in split shade and there are no notifications, the height can be too low, as
            // it is based on notifications bottom, which is lower on split shade.
            // Here we prefer to use at least a minimum height defined for split shade.
            // Otherwise the expansion motion is too fast.
            contentHeight = Math.max(mSplitShadeMinContentHeight, getContentHeight());
        } else {
            contentHeight = getContentHeight();
        }
        return Math.max(mMaxLayoutHeight - contentHeight, 0);
    }

    void onExpansionStarted() {
        mIsExpansionChanging = true;
        mAmbientState.setExpansionChanging(true);
    }

    void onExpansionStopped() {
        mIsExpansionChanging = false;
        mAmbientState.setExpansionChanging(false);
        if (!mIsExpanded) {
            resetScrollPosition();
            mResetUserExpandedStatesRunnable.run();
            clearTemporaryViews();
            clearUserLockedViews();
            resetAllSwipeState();
        }
    }

    private void clearUserLockedViews() {
        for (int i = 0; i < getChildCount(); i++) {
            ExpandableView child = getChildAtIndex(i);
            if (child instanceof ExpandableNotificationRow row) {
                row.setUserLocked(false);
            }
        }
    }

    private void clearTemporaryViews() {
        // lets make sure nothing is transient anymore
        clearTemporaryViewsInGroup(
                /* viewGroup = */ this,
                /* reason = */ "clearTemporaryViews"
        );
        for (int i = 0; i < getChildCount(); i++) {
            ExpandableView child = getChildAtIndex(i);
            if (child instanceof ExpandableNotificationRow row) {
                clearTemporaryViewsInGroup(
                        /* viewGroup = */ row.getChildrenContainer(),
                        /* reason = */ "clearTemporaryViewsInGroup(row.getChildrenContainer())"
                );
            }
        }
    }

    private void clearTemporaryViewsInGroup(ViewGroup viewGroup, String reason) {
        while (viewGroup != null && viewGroup.getTransientViewCount() != 0) {
            final View transientView = viewGroup.getTransientView(0);
            viewGroup.removeTransientView(transientView);
            if (transientView instanceof ExpandableView) {
                ((ExpandableView) transientView).setTransientContainer(null);
                if (transientView instanceof ExpandableNotificationRow) {
                    logTransientNotificationRowTraversalCleaned(
                            (ExpandableNotificationRow) transientView,
                            reason
                    );
                }
            }
        }
    }

    private void resetChildAlpha() {
        for (int i = 0; i < getChildCount(); i++) {
            ExpandableView child = getChildAtIndex(i);
            if (child instanceof ExpandableNotificationRow row) {
                if (row.isExpandAnimationRunning()) continue;
                row.resetAllContentAlphas();
            }
        }
    }

    private void logTransientNotificationRowTraversalCleaned(
            ExpandableNotificationRow transientView,
            String reason
    ) {
        if (mLogger == null) {
            return;
        }
        mLogger.transientNotificationRowTraversalCleaned(transientView.getLoggingKey(), reason);
    }

    void onPanelTrackingStarted() {
        mPanelTracking = true;
        mAmbientState.setPanelTracking(true);
        resetExposedMenuView(true /* animate */, true /* force */);
    }

    void onPanelTrackingStopped() {
        mPanelTracking = false;
        mAmbientState.setPanelTracking(false);
    }

    void resetScrollPosition() {
        mScroller.abortAnimation();
        setOwnScrollY(0);
    }

    @VisibleForTesting
    void setIsExpanded(boolean isExpanded) {
        boolean changed = isExpanded != mIsExpanded;
        mIsExpanded = isExpanded;
        mStackScrollAlgorithm.setIsExpanded(isExpanded);
        mAmbientState.setShadeExpanded(isExpanded);
        mStateAnimator.setShadeExpanded(isExpanded);
        mSwipeHelper.setIsExpanded(isExpanded);
        if (changed) {
            mWillExpand = false;
            if (mIsExpanded) {
                // Resetting headsUpAnimatingAway on Shade expansion avoids delays caused by
                // waiting for all child animations to finish.
                // TODO(b/328390331) Do we need to reset this on QS expanded as well?
                if (SceneContainerFlag.isEnabled()) {
                    setHeadsUpAnimatingAway(false);
                }
                resetChildAlpha();
            } else {
                mGroupExpansionManager.collapseGroups();
                mExpandHelper.cancelImmediately();
                if (!mIsExpansionChanging) {
                    resetAllSwipeState();
                }
                finalizeClearAllAnimation();
            }
            updateNotificationAnimationStates();
            updateChronometers();
            requestChildrenUpdate();
            updateUseRoundedRectClipping();
            updateDismissBehavior();
        }
    }

    private void updateChronometers() {
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            updateChronometerForChild(getChildAt(i));
        }
    }

    void updateChronometerForChild(View child) {
        if (child instanceof ExpandableNotificationRow row) {
            row.setChronometerRunning(mIsExpanded);
        }
    }

    void onChildHeightChanged(ExpandableView view, boolean needsAnimation) {
        boolean previouslyNeededAnimation = mAnimateStackYForContentHeightChange;
        if (needsAnimation) {
            mAnimateStackYForContentHeightChange = true;
        }
        updateContentHeight();
        updateScrollPositionOnExpandInBottom(view);
        clampScrollPosition();
        notifyHeightChangeListener(view, needsAnimation);
        ExpandableNotificationRow row = view instanceof ExpandableNotificationRow
                ? (ExpandableNotificationRow) view
                : null;
        NotificationSection firstSection = getFirstVisibleSection();
        ExpandableView firstVisibleChild =
                firstSection == null ? null : firstSection.getFirstVisibleChild();
        if (row != null) {
            // TODO(b/424163539): child height updated logs are spammy, which hides other logs
            // logChildHeightUpdated(row, needsAnimation);
            if (row == firstVisibleChild
                    || row.getNotificationParent() == firstVisibleChild) {
                updateAlgorithmLayoutMinHeight();
            }
        }
        if (needsAnimation) {
            requestAnimationOnViewResize(row);
        }
        requestChildrenUpdate();
        notifyHeadsUpHeightChangedForView(view);
        mAnimateStackYForContentHeightChange = previouslyNeededAnimation;
    }


    private void logChildHeightUpdated(ExpandableNotificationRow row, boolean needsAnimation) {
        if (mLogger != null) {
            mLogger.childHeightUpdated(row, needsAnimation);
        }
    }

    void onChildHeightReset(ExpandableView view) {
        updateAnimationState(view);
        updateChronometerForChild(view);
        notifyHeadsUpHeightChangedForView(view);
    }

    private void updateScrollPositionOnExpandInBottom(ExpandableView view) {
        if (view instanceof ExpandableNotificationRow row && !onKeyguard()) {
            // TODO: once we're recycling this will need to check the adapter position of the child
            if (row.isUserLocked() && row != getFirstChildNotGoneInternal()) {
                if (row.isSummaryWithChildren()) {
                    return;
                }
                // We are actually expanding this view
                float endPosition = row.getTranslationY() + row.getActualHeight();
                if (row.isChildInGroup()) {
                    endPosition += row.getNotificationParent().getTranslationY();
                }
                int layoutEnd = mMaxLayoutHeight + (int) getStackTranslation();
                NotificationSection lastSection = getLastVisibleSection();
                ExpandableView lastVisibleChild =
                        lastSection == null ? null : lastSection.getLastVisibleChild();
                if (row != lastVisibleChild && mShelf.getVisibility() != GONE) {
                    layoutEnd -= mShelf.getIntrinsicHeight() + mPaddingBetweenElements;
                }
                if (endPosition > layoutEnd) {
                    // if Scene Container is active, send bottom notification expansion delta
                    // to it so that it can scroll the stack and scrim accordingly.
                    if (SceneContainerFlag.isEnabled()) {
                        float diff = endPosition - layoutEnd;
                        mScrollViewFields.sendSyntheticScroll(diff);
                    }
                    setOwnScrollY((int) (getOwnScrollY() + endPosition - layoutEnd));
                    mDisallowScrollingInThisMotion = true;
                }
            }
        }
    }

    void setOnHeightChangedListener(
            ExpandableView.OnHeightChangedListener onHeightChangedListener) {
        this.mOnHeightChangedListener = onHeightChangedListener;
    }

    void setOnHeightChangedRunnable(Runnable r) {
        this.mOnHeightChangedRunnable = r;
    }

    void setOnKeyguardTopLevelNotificationRemovedRunnable(Runnable r) {
        this.mOnKeyguardTopLevelNotificationRemovedRunnable = r;
    }

    void onChildAnimationFinished() {
        setAnimationRunning(false);
        if (SceneContainerFlag.isEnabled()) {
            setHeadsUpAnimatingAway(false);
        }
        requestChildrenUpdate();
        runAnimationFinishedRunnables();
        clearTransient();
        clearHeadsUpDisappearRunning();
        finalizeClearAllAnimation();
    }

    private void finalizeClearAllAnimation() {
        if (mAmbientState.isClearAllInProgress()) {
            setClearAllInProgress(false);
            if (mShadeNeedsToClose) {
                mShadeNeedsToClose = false;
                if (mIsExpanded) {
                    mClearAllFinishedWhilePanelExpandedRunnable.run();
                }
            }
        }
    }

    private void clearHeadsUpDisappearRunning() {
        for (int i = 0; i < getChildCount(); i++) {
            View view = getChildAt(i);
            if (view instanceof ExpandableNotificationRow row) {
                row.setHeadsUpAnimatingAway(false);
                if (row.isSummaryWithChildren()) {
                    for (ExpandableNotificationRow child : row.getAttachedChildren()) {
                        child.setHeadsUpAnimatingAway(false);
                    }
                }
            }
        }
    }

    private void clearTransient() {
        for (ExpandableView view : mClearTransientViewsWhenFinished) {
            view.removeFromTransientContainer();
        }
        mClearTransientViewsWhenFinished.clear();
    }

    private void runAnimationFinishedRunnables() {
        for (Runnable runnable : mAnimationFinishedRunnables) {
            runnable.run();
        }
        mAnimationFinishedRunnables.clear();
    }

    void updateSensitiveness(boolean animate, boolean hideSensitive) {
        if (hideSensitive != mAmbientState.isHideSensitive()) {
            int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                ExpandableView v = getChildAtIndex(i);
                v.setHideSensitiveForIntrinsicHeight(hideSensitive);
            }
            mAmbientState.setHideSensitive(hideSensitive);
            if (animate && mAnimationsEnabled) {
                mHideSensitiveNeedsAnimation = true;
                mNeedsAnimation = true;
            }
            updateContentHeight();
            requestChildrenUpdate();
        }
    }

    private void applyCurrentState() {
        int numChildren = getChildCount();
        for (int i = 0; i < numChildren; i++) {
            ExpandableView child = getChildAtIndex(i);
            child.applyViewState();
        }

        if (mLocationsChangedListener != null) {
            mLocationsChangedListener.onChildLocationsChanged(collectVisibleLocationsCallable);
        }

        runAnimationFinishedRunnables();
        setAnimationRunning(false);
        updateViewShadows();
    }

    /**
     * Retrieves a map of visible [{@link ExpandableViewState#location}]s of the actively displayed
     * Notification children associated by their Notification keys.
     * Locations are collected recursively including locations from the child views of Notification
     * Groups, that are visible.
     */
    private Map<String, Integer> collectVisibleNotificationLocations() {
        Map<String, Integer> visibilities = new HashMap<>();
        int numChildren = getChildCount();
        for (int i = 0; i < numChildren; i++) {
            ExpandableView child = getChildAtIndex(i);
            if (child instanceof ExpandableNotificationRow row) {
                row.collectVisibleLocations(visibilities);
            }
        }
        return visibilities;
    }

    private void updateViewShadows() {
        // we need to work around an issue where the shadow would not cast between siblings when
        // their z difference is between 0 and 0.1

        // Lefts first sort by Z difference
        for (int i = 0; i < getChildCount(); i++) {
            ExpandableView child = getChildAtIndex(i);
            if (child.getVisibility() != GONE) {
                mTmpSortedChildren.add(child);
            }
        }
        Collections.sort(mTmpSortedChildren, mViewPositionComparator);

        // Now lets update the shadow for the views
        ExpandableView previous = null;
        for (int i = 0; i < mTmpSortedChildren.size(); i++) {
            ExpandableView expandableView = mTmpSortedChildren.get(i);
            float translationZ = expandableView.getTranslationZ();
            float otherZ = previous == null ? translationZ : previous.getTranslationZ();
            float diff = otherZ - translationZ;
            if (diff <= 0.0f || diff >= FakeShadowView.SHADOW_SIBLING_TRESHOLD) {
                // There is no fake shadow to be drawn
                expandableView.setFakeShadowIntensity(0.0f, 0.0f, 0, 0);
            } else {
                float yLocation = previous.getTranslationY() + previous.getActualHeight() -
                        expandableView.getTranslationY();
                expandableView.setFakeShadowIntensity(
                        diff / FakeShadowView.SHADOW_SIBLING_TRESHOLD,
                        previous.getOutlineAlpha(), (int) yLocation,
                        (int) (previous.getOutlineTranslation() + previous.getTranslation()));
            }
            previous = expandableView;
        }

        mTmpSortedChildren.clear();
    }

    /**
     * Update colors of section headers, shade footer, and empty shade views.
     */
    void updateDecorViews() {
        final @ColorInt int onSurface = mContext.getColor(
                com.android.internal.R.color.materialColorOnSurface);
        final @ColorInt int onSurfaceVariant = mContext.getColor(
                com.android.internal.R.color.materialColorOnSurfaceVariant);

        ColorUpdateLogger colorUpdateLogger = ColorUpdateLogger.getInstance();
        if (colorUpdateLogger != null) {
            colorUpdateLogger.logEvent("NSSL.updateDecorViews()",
                    "onSurface=" + ColorUtilKt.hexColorString(onSurface)
                            + " onSurfaceVariant=" + ColorUtilKt.hexColorString(onSurfaceVariant));
        }

        mSectionsManager.setHeaderForegroundColors(onSurface, onSurfaceVariant);

        if (mFooterView != null) {
            mFooterView.updateColors();
        }

        if (mEmptyShadeView != null) {
            if (ShowIconInEmptyShade.isEnabled()) {
                ((EmptyShadeIconView) mEmptyShadeView).setContentColor(onSurface);
            } else {
                ((EmptyShadeView) mEmptyShadeView).setTextColors(onSurface, onSurfaceVariant);
            }
        }
    }

    /**
     * Requests an animation for the next stack height update, to animate from the constrained stack
     * displayed on the lock screen, to the scrollable stack displayed in the expanded shade.
     */
    public void animateGoToFullShade(long delay) {
        mGoToFullShadeNeedsAnimation = true;
        mGoToFullShadeDelay = delay;
        mNeedsAnimation = true;
        requestChildrenUpdate();
    }

    public void cancelExpandHelper() {
        mExpandHelper.cancel();
    }

    void setIntrinsicPadding(int intrinsicPadding) {
        SceneContainerFlag.assertInLegacyMode();
        mIntrinsicPadding = intrinsicPadding;
    }

    /**
     * Distance from the top of the screen in, where notifications should start when fully expanded
     * or in the LS.
     *
     * Always 0 with SceneContainer enabled.
     */
    int getIntrinsicPadding() {
        if (SceneContainerFlag.isEnabled()) {
            return 0;
        }
        return mIntrinsicPadding;
    }

    @Override
    public boolean shouldDelayChildPressedState() {
        return true;
    }

    /**
     * See {@link AmbientState#setDozing}.
     */
    @Override
    public void setDozing(boolean dozing) {
        if (mAmbientState.isDozing() == dozing) {
            return;
        }
        mAmbientState.setDozing(dozing);
        requestChildrenUpdate();
        notifyHeightChangeListener(mShelf);
    }

    /**
     * Sets the current hide amount.
     *
     * @param linearHideAmount       The hide amount that follows linear interpoloation in the
     *                               animation,
     *                               i.e. animates from 0 to 1 or vice-versa in a linear manner.
     * @param interpolatedHideAmount The hide amount that follows the actual interpolation of the
     *                               animation curve.
     */
    void setHideAmount(float linearHideAmount, float interpolatedHideAmount) {
        mLinearHideAmount = linearHideAmount;
        mInterpolatedHideAmount = interpolatedHideAmount;
        boolean wasFullyHidden = mAmbientState.isFullyHidden();
        boolean wasHiddenAtAll = mAmbientState.isHiddenAtAll();
        mAmbientState.setHideAmount(interpolatedHideAmount);
        boolean nowFullyHidden = mAmbientState.isFullyHidden();
        boolean nowHiddenAtAll = mAmbientState.isHiddenAtAll();
        if (nowFullyHidden != wasFullyHidden) {
            updateVisibility();
            resetAllSwipeState();
        }
        if (!wasHiddenAtAll && nowHiddenAtAll) {
            resetExposedMenuView(true /* animate */, true /* animate */);
        }
        if (nowFullyHidden != wasFullyHidden || wasHiddenAtAll != nowHiddenAtAll) {
            invalidateOutline();
        }
        updateAlgorithmHeightAndPadding();
        requestChildrenUpdate();
        updateOwnTranslationZ();
    }

    private void updateOwnTranslationZ() {
        // Since we are clipping to the outline we need to make sure that the shadows aren't
        // clipped when pulsing
        float ownTranslationZ = 0;
        if (mKeyguardBypassEnabled && mAmbientState.isHiddenAtAll()) {
            ExpandableView firstChildNotGone = getFirstChildNotGoneInternal();
            if (firstChildNotGone != null && firstChildNotGone.showingPulsing()) {
                ownTranslationZ = firstChildNotGone.getTranslationZ();
            }
        }
        setTranslationZ(ownTranslationZ);
    }

    private void updateVisibility() {
        mController.updateVisibility(!mAmbientState.isFullyHidden() || !onKeyguard());
    }

    void notifyHideAnimationStart(boolean hide) {
        // We only swap the scaling factor if we're fully hidden or fully awake to avoid
        // interpolation issues when playing with the power button.
        if (mInterpolatedHideAmount == 0 || mInterpolatedHideAmount == 1) {
            mBackgroundXFactor = hide ? 1.8f : 1.5f;
            mHideXInterpolator = hide
                    ? Interpolators.FAST_OUT_SLOW_IN_REVERSE
                    : Interpolators.FAST_OUT_SLOW_IN;
        }
    }

    /** Bind the {@link FooterView} to the NSSL. */
    public void setFooterView(@NonNull FooterView footerView) {
        int index = -1;
        if (mFooterView != null) {
            index = indexOfChild(mFooterView);
            removeView(mFooterView);
        }
        mFooterView = footerView;
        addView(mFooterView, index);
    }

    /** Bind the {@link EmptyShadeView} to the NSSL. */
    public void setEmptyShadeView(StackScrollerDecorView emptyShadeView) {
        int index = -1;
        if (mEmptyShadeView != null) {
            index = indexOfChild(mEmptyShadeView);
            removeView(mEmptyShadeView);
        }
        mEmptyShadeView = emptyShadeView;
        addView(mEmptyShadeView, index);
    }

    public boolean isEmptyShadeViewVisible() {
        SceneContainerFlag.assertInLegacyMode();
        if (mEmptyShadeView == null) {
            return false;
        }
        return mEmptyShadeView.isVisible();
    }

    @VisibleForTesting
    public void setClearAllInProgress(boolean clearAllInProgress) {
        mClearAllInProgress = clearAllInProgress;
        mAmbientState.setClearAllInProgress(clearAllInProgress);
        mController.getNotificationRoundnessManager().setClearAllInProgress(clearAllInProgress);
    }

    boolean getClearAllInProgress() {
        return mClearAllInProgress;
    }

    /**
     * @return the padding after the media header on the lockscreen
     */
    public int getPaddingAfterMedia() {
        return mGapHeight + mPaddingBetweenElements;
    }

    public int getEmptyShadeViewHeight() {
        SceneContainerFlag.assertInLegacyMode();
        return mEmptyShadeView.getHeight();
    }

    public float getBottomMostNotificationBottom() {
        SceneContainerFlag.assertInLegacyMode();
        final int count = getChildCount();
        float max = 0;
        for (int childIdx = 0; childIdx < count; childIdx++) {
            ExpandableView child = getChildAtIndex(childIdx);
            if (child.getVisibility() == GONE) {
                continue;
            }
            int clipBottomAmount = Math.max(child.getClipBottomAmount(), child.getBottomOverlap());
            float bottom = child.getTranslationY() + child.getActualHeight()
                    - clipBottomAmount;
            if (bottom > max) {
                max = bottom;
            }
        }
        return max + getStackTranslation();
    }

    public void setResetUserExpandedStatesRunnable(Runnable runnable) {
        this.mResetUserExpandedStatesRunnable = runnable;
    }

    void requestAnimateEverything() {
        if (mIsExpanded && mAnimationsEnabled) {
            mEverythingNeedsAnimation = true;
            mNeedsAnimation = true;
            requestChildrenUpdate();
        }
    }

    public boolean isBelowLastNotification(float touchX, float touchY) {
        SceneContainerFlag.assertInLegacyMode();
        int childCount = getChildCount();
        for (int i = childCount - 1; i >= 0; i--) {
            ExpandableView child = getChildAtIndex(i);
            if (child.getVisibility() != View.GONE) {
                float childTop = child.getY();
                if (childTop > touchY) {
                    // we are above a notification entirely let's abort
                    return false;
                }
                int clipBottomAmount = Math.max(child.getClipBottomAmount(),
                        child.getBottomOverlap());
                boolean belowChild = touchY > childTop + child.getActualHeight()
                        - clipBottomAmount;
                if (child == mFooterView) {
                    if (!belowChild && !mFooterView.isOnEmptySpace(touchX - mFooterView.getX(),
                            touchY - childTop)) {
                        // We clicked on the dismiss button
                        return false;
                    }
                } else if (child == mEmptyShadeView) {
                    // We arrived at the empty shade view, for which we accept all clicks
                    return true;
                } else if (!belowChild) {
                    // We are on a child
                    return false;
                }
            }
        }
        return touchY > getTopPadding() + getStackTranslation();
    }

    /**
     * @hide
     */
    @Override
    public void onInitializeAccessibilityEventInternal(AccessibilityEvent event) {
        super.onInitializeAccessibilityEventInternal(event);
        event.setScrollable(mScrollable);
        event.setMaxScrollX(mScrollX);

        if (SceneContainerFlag.isEnabled()) {
            event.setScrollY(mScrollViewFields.scrollState.getScrollPosition());
            event.setMaxScrollY(mScrollViewFields.scrollState.getMaxScrollPosition());
        } else {
            event.setScrollY(getOwnScrollY());
            event.setMaxScrollY(getScrollRange());
        }
    }

    @Override
    public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
        super.onInitializeAccessibilityNodeInfoInternal(info);
        if (mScrollable) {
            info.setScrollable(true);
            if (mBackwardScrollable) {
                info.addAction(
                        AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
                info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_UP);
            }
            if (mForwardScrollable) {
                info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
                info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_DOWN);
            }
        }
        // Talkback only listenes to scroll events of certain classes, let's make us a scrollview
        info.setClassName(ScrollView.class.getName());
    }

    public int getContainerChildCount() {
        return getChildCount();
    }

    public View getContainerChildAt(int i) {
        return getChildAt(i);
    }

    public void removeContainerView(View v) {
        Assert.isMainThread();
        removeView(v);
        updateSpeedBumpIndex();
    }

    public void addContainerView(View v) {
        Assert.isMainThread();
        addView(v);
        updateSpeedBumpIndex();
    }

    public void addContainerViewAt(View v, int index) {
        Assert.isMainThread();
        ensureRemovedFromTransientContainer(v);
        addView(v, index);
        updateSpeedBumpIndex();
    }

    private void ensureRemovedFromTransientContainer(View v) {
        if (v.getParent() != null && v instanceof ExpandableView) {
            // If the child is animating away, it will still have a parent, so detach it first
            // TODO: We should really cancel the active animations here. This will
            //  happen automatically when the view's intro animation starts, but
            //  it's a fragile link.
            ((ExpandableView) v).removeFromTransientContainerForAdditionTo(this);
        }
    }

    public void runAfterAnimationFinished(Runnable runnable) {
        mAnimationFinishedRunnables.add(runnable);
    }

    public void generateHeadsUpAnimation(
            NotificationEntry entry, boolean isHeadsUp, @Nullable RectF statusBarChipBounds) {
        SceneContainerFlag.assertInLegacyMode();
        ExpandableNotificationRow row = entry.getHeadsUpAnimationView();
        generateHeadsUpAnimation(row, isHeadsUp, statusBarChipBounds);
    }

    /**
     * Notifies the NSSL, that the given view would need a HeadsUp animation, when it is being
     * added to this container.
     *
     * @param row to animate
     * @param isHeadsUp true for appear, false for disappear animations
     * @param statusBarChipBounds the on-screen bounds of the status bar chip currently displayed
     *                            for the given notification, or null if there is no status bar chip
     */
    public void generateHeadsUpAnimation(
            ExpandableNotificationRow row,
            boolean isHeadsUp,
            @Nullable RectF statusBarChipBounds) {
        // TODO(b/393369891): Use the chip bounds to animate the heads up animation from the chip.
        boolean hasStatusBarChip = statusBarChipBounds != null;
        boolean addAnimation =
                mAnimationsEnabled && (isHeadsUp || mHeadsUpGoingAwayAnimationsAllowed);
        boolean hasBackingData = NotificationBundleUi.isEnabled()
                ? row.getEntryAdapter() != null
                : row.getEntryLegacy() != null;
        boolean isSeenInShade = hasBackingData
                ? (NotificationBundleUi.isEnabled()
                    ? row.getEntryAdapter().isSeenInShade()
                    : row.getEntryLegacy().isSeenInShade())
                : false;
        if (NotificationThrottleHun.isEnabled()) {
            final boolean closedAndSeenInShade = !mIsExpanded && isSeenInShade;
            addAnimation = addAnimation && !closedAndSeenInShade;
        }
        if (SPEW) {
            Log.v(TAG, "generateHeadsUpAnimation:"
                    + " addAnimation=" + addAnimation
                    + (!hasBackingData ? " entry NULL "
                            : " isSeenInShade=" + isSeenInShade
                                    + " row=" + row.getKey())
                    + " mIsExpanded=" + mIsExpanded
                    + " isHeadsUp=" + isHeadsUp
                    + " hasStatusBarChip=" + hasStatusBarChip);
        }

        if (addAnimation) {
            // If we're hiding a HUN we just started showing THIS FRAME, then remove that event,
            // and do not add the disappear event either.
            boolean showingHunThisFrame =
                    mHeadsUpChangeAnimations.containsKey(row)
                            && mHeadsUpChangeAnimations.get(row).isHeadsUpAppearance();
            if (!isHeadsUp && showingHunThisFrame) {
                mHeadsUpChangeAnimations.remove(row);
                if (SPEW) {
                    Log.v(TAG, "generateHeadsUpAnimation: previous hun appear animation cancelled");
                }
                logHunAnimationSkipped(row, "previous hun appear animation cancelled");
                return;
            }
            mHeadsUpChangeAnimations.put(
                    row, new HeadsUpAnimationEvent(row, isHeadsUp, hasStatusBarChip));
            mNeedsAnimation = true;
            if (!mIsExpanded && !mWillExpand && !isHeadsUp) {
                row.setHeadsUpAnimatingAway(true);
                if (SceneContainerFlag.isEnabled()) {
                    setHeadsUpAnimatingAway(true);
                }
            }
            if (PromotedNotificationUi.isEnabled()) {
                row.setHasStatusBarChipDuringHeadsUpAnimation(hasStatusBarChip);
            }
            requestChildrenUpdate();
        }
    }

    /**
     * Set the boundary for the bottom heads up position. The heads up will always be above this
     * position.
     *
     * @param height          the height of the screen
     * @param bottomBarHeight the height of the bar on the bottom
     */
    public void setHeadsUpBoundaries(int height, int bottomBarHeight) {
        SceneContainerFlag.assertInLegacyMode();
        mAmbientState.setMaxHeadsUpTranslation(height - bottomBarHeight);

        mHeadsUpAnimator.setHeadsUpAppearHeightBottom(height);
        mHeadsUpAnimator.setStackTopMargin(mAmbientState.getStackTopMargin());

        requestChildrenUpdate();
    }

    public void setWillExpand(boolean willExpand) {
        mWillExpand = willExpand;
    }

    public void setTrackingHeadsUp(ExpandableNotificationRow row) {
        mAmbientState.setTrackedHeadsUpRow(row);
    }

    public void forceNoOverlappingRendering(boolean force) {
        mForceNoOverlappingRendering = force;
    }

    @Override
    public boolean hasOverlappingRendering() {
        return !mForceNoOverlappingRendering && super.hasOverlappingRendering();
    }

    public void setAnimationRunning(boolean animationRunning) {
        if (animationRunning != mAnimationRunning) {
            if (animationRunning) {
                getViewTreeObserver().addOnPreDrawListener(mRunningAnimationUpdater);
            } else {
                getViewTreeObserver().removeOnPreDrawListener(mRunningAnimationUpdater);
            }
            mAnimationRunning = animationRunning;
            updateContinuousShadowDrawing();
        }
    }

    public boolean isExpanded() {
        return mIsExpanded;
    }

    public void setPulsing(boolean pulsing, boolean animated) {
        if (!mPulsing && !pulsing) {
            return;
        }
        mPulsing = pulsing;
        mAmbientState.setPulsing(pulsing);
        mSwipeHelper.setPulsing(pulsing);
        updateNotificationAnimationStates();
        updateAlgorithmHeightAndPadding();
        updateContentHeight();
        requestChildrenUpdate();
        notifyHeightChangeListener(null, animated);
    }

    public void setQsFullScreen(boolean qsFullScreen) {
        SceneContainerFlag.assertInLegacyMode();
        if (qsFullScreen == mQsFullScreen) {
            return;  // no change
        }
        mQsFullScreen = qsFullScreen;
        updateAlgorithmLayoutMinHeight();
        updateScrollability();
    }

    boolean isQsFullScreen() {
        SceneContainerFlag.assertInLegacyMode();
        return mQsFullScreen;
    }

    private float getQsExpansionFraction() {
        SceneContainerFlag.assertInLegacyMode();
        return mQsExpansionFraction;
    }

    public void setQsExpansionFraction(float qsExpansionFraction) {
        SceneContainerFlag.assertInLegacyMode();
        mQsExpansionFraction = qsExpansionFraction;
        updateUseRoundedRectClipping();

        // If notifications are scrolled,
        // clear out scrollY by the time we push notifications offscreen
        if (getOwnScrollY() > 0) {
            setOwnScrollY((int) MathUtils.lerp(getOwnScrollY(), 0, getQsExpansionFraction()));
        }
    }

    @VisibleForTesting
    int getOwnScrollY() {
        if (SceneContainerFlag.isEnabled()) {
            return 0;
        } else {
            return mOwnScrollY;
        }
    }

    @VisibleForTesting
    void setOwnScrollY(int ownScrollY) {
        setOwnScrollY(ownScrollY, false /* animateScrollChangeListener */);
    }

    private void setOwnScrollY(int ownScrollY, boolean animateStackYChangeListener) {
        // If scene container is active, NSSL should not control its own scrolling.
        if (SceneContainerFlag.isEnabled()) {
            return;
        }
        // Avoid Flicking during clear all
        // when the shade finishes closing, onExpansionStopped will call
        // resetScrollPosition to setOwnScrollY to 0
        if (mAmbientState.isClosing() || mAmbientState.isClearAllInProgress()) {
            return;
        }

        if (ownScrollY != getOwnScrollY()) {
            // We still want to call the normal scrolled changed for accessibility reasons
            onScrollChanged(mScrollX, ownScrollY, mScrollX, getOwnScrollY());
            mOwnScrollY = ownScrollY;
            mAmbientState.setScrollY(getOwnScrollY());
            updateOnScrollChange();
            updateStackPosition(animateStackYChangeListener);
        }
    }

    private void updateOnScrollChange() {
        SceneContainerFlag.assertInLegacyMode();
        if (mScrollListener != null) {
            mScrollListener.accept(getOwnScrollY());
        }
        updateForwardAndBackwardScrollability();
        requestChildrenUpdate();
    }

    @Nullable
    public ExpandableView getShelf() {
        return mShelf;
    }

    public void setShelf(NotificationShelf shelf) {
        int index = -1;
        if (mShelf != null) {
            index = indexOfChild(mShelf);
            removeView(mShelf);
        }
        mShelf = shelf;
        addView(mShelf, index);
        mAmbientState.setShelf(mShelf);
        mStateAnimator.setShelf(mShelf);
        shelf.bind(mAmbientState, this, mController.getNotificationRoundnessManager());
    }

    /**
     * Whether the notifications are displayed over the unoccluded lockscreen. Returns false on the
     * locked shade.
     */
    public boolean isOnLockscreen() {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return false;
        return mIsOnLockscreen;
    }

    /** @see #isOnLockscreen() */
    @Override
    public void setOnLockscreen(boolean isOnLockscreen) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        if (mIsOnLockscreen != isOnLockscreen) {
            mIsOnLockscreen = isOnLockscreen;
            for (int i = 0; i < getChildCount(); i++) {
                View child = getChildAt(i);
                if (child instanceof ExpandableNotificationRow childRow) {
                    childRow.setOnKeyguard(isOnLockscreen);
                }
            }
            mShelf.setOnKeyguard(isOnLockscreen);
        }
    }

    @Override
    public int calculateMaxNotifications(int availableSpace, boolean useExtraShelfSpace) {
        int shelfHeight = mShelf != null ? mShelf.getIntrinsicHeight() : 0;
        return mNotificationStackSizeCalculator.computeMaxKeyguardNotifications(
                /* stack = */ this,
                /* space = */ availableSpace,
                /* shelfHeight = */ shelfHeight,
                /* extraShelfSpace = */ useExtraShelfSpace ? shelfHeight : 0
        );
    }

    @Override
    public void setMaxDisplayedNotifications(int maxDisplayedNotifications) {
        if (mMaxDisplayedNotifications != maxDisplayedNotifications) {
            if (mLogger != null) {
                mLogger.setMaxDisplayedNotifications(maxDisplayedNotifications);
            }
            mMaxDisplayedNotifications = maxDisplayedNotifications;
            if (SceneContainerFlag.isEnabled()) {
                updateIntrinsicStackHeight();
                updateStackEndHeightAndStackHeight(mAmbientState.getExpansionFraction());
                if (maxDisplayedNotifications == -1) {
                    animateGoToFullShade(0);
                }
            } else {
                updateContentHeight();
            }
            notifyHeightChangeListener(mShelf);
        }
    }

    /**
     * This is used for debugging only; it will be used to draw the otherwise invisible line which
     * NotificationPanelViewController treats as the bottom when calculating how many notifications
     * appear on the keyguard.
     * Setting a negative number will disable rendering this line.
     */
    public void setKeyguardBottomPadding(float keyguardBottomPadding) {
        mKeyguardBottomPadding = keyguardBottomPadding;
    }

    public void setShouldShowShelfOnly(boolean shouldShowShelfOnly) {
        mShouldShowShelfOnly = shouldShowShelfOnly;
        updateAlgorithmLayoutMinHeight();
    }

    public int getMinExpansionHeight() {
        // shelf height is defined in dp but status bar height can be defined in px, that makes
        // relation between them variable - sometimes one might be bigger than the other when
        // changing density. That’s why we need to ensure we’re not subtracting negative value below
        return mShelf.getIntrinsicHeight()
                - Math.max(0,
                (mShelf.getIntrinsicHeight() - mStatusBarHeight + mWaterfallTopInset) / 2)
                + mWaterfallTopInset;
    }

    public void setInHeadsUpPinnedMode(boolean inHeadsUpPinnedMode) {
        mInHeadsUpPinnedMode = inHeadsUpPinnedMode;
        updateClipping();
    }

    /** TODO(b/328390331) make this private, when {@link SceneContainerFlag} is removed */
    public void setHeadsUpAnimatingAway(boolean headsUpAnimatingAway) {
        if (mHeadsUpAnimatingAway != headsUpAnimatingAway) {
            mHeadsUpAnimatingAway = headsUpAnimatingAway;
            if (mHeadsUpAnimatingAwayListener != null) {
                mHeadsUpAnimatingAwayListener.accept(headsUpAnimatingAway);
            }
        }
        updateClipping();
    }

    /**
     * Sets a listener to be notified about the heads up disappear animation state changes. If there
     * are overlapping animations, it will receive updates when the first disappar animation has
     * started, and when the last has finished.
     *
     * @param headsUpAnimatingAwayListener to be notified about disappear animation state changes.
     */
    public void setHeadsUpAnimatingAwayListener(
            Consumer<Boolean> headsUpAnimatingAwayListener) {
        mHeadsUpAnimatingAwayListener = headsUpAnimatingAwayListener;
    }
    @VisibleForTesting
    public void setStatusBarState(int statusBarState) {
        mStatusBarState = statusBarState;
        mAmbientState.setStatusBarState(statusBarState);
        if (!SceneContainerFlag.isEnabled()) {
            mShelf.setOnKeyguard(statusBarState == StatusBarState.KEYGUARD);
        }
        updateSpeedBumpIndex();
        updateDismissBehavior();
    }

    @Override
    public void setShowingStackOnLockscreen(boolean showingStackOnLockscreen) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mAmbientState.setShowingStackOnLockscreen(showingStackOnLockscreen);
    }

    @Override
    public void setAlphaForLockscreenFadeIn(float alphaForLockscreenFadeIn) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mAmbientState.setLockscreenStackFadeInProgress(alphaForLockscreenFadeIn);
        requestChildrenUpdate();
    }

    void onStatePostChange(boolean fromShadeLocked) {
        boolean onKeyguard = onKeyguard();

        if (mHeadsUpAppearanceController != null && !StatusBarNoHunBehavior.isEnabled()) {
            mHeadsUpAppearanceController.onStateChanged();
        }

        setExpandingEnabled(!onKeyguard);
        requestChildrenUpdate();
        onUpdateRowStates();
        updateVisibility();
    }

    public void setExpandingVelocity(float expandingVelocity) {
        SceneContainerFlag.assertInLegacyMode();
        mAmbientState.setExpandingVelocity(expandingVelocity);
    }

    public float getOpeningHeight() {
        SceneContainerFlag.assertInLegacyMode();
        if (mEmptyShadeView == null || mEmptyShadeView.getVisibility() == GONE) {
            return getMinExpansionHeight();
        } else {
            return getAppearEndPosition();
        }
    }

    public void setIsFullWidth(boolean isFullWidth) {
        mAmbientState.setSmallScreen(isFullWidth);
    }

    public void setPanelFlinging(boolean flinging) {
        SceneContainerFlag.assertInLegacyMode();
        mAmbientState.setFlinging(flinging);
        if (!flinging) {
            // re-calculate the stack height which was frozen while flinging
            updateStackPosition();
        }
    }

    @Override
    public void suppressHeightUpdates(boolean suppress) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        mSuppressHeightUpdates = suppress;
    }

    public void setHeadsUpGoingAwayAnimationsAllowed(boolean headsUpGoingAwayAnimationsAllowed) {
        mHeadsUpGoingAwayAnimationsAllowed = headsUpGoingAwayAnimationsAllowed;
    }

    /**
     * Dumps debug info for ActivatableNotificationView appearing with invalid outline
     */
    private void verifyOutline(IndentingPrintWriter pw, ExpandableView ev) {
        if (!(ev instanceof ActivatableNotificationView anv)) {
            return;
        }
        if (!anv.isDrawingAppearAnimation()) {
            return;
        }
        boolean hasInvalidOutline = false;
        StringBuilder detailStr = new StringBuilder();

        if (anv.hasCustomOutline()) {
            Rect or = anv.getOutlineRect();
            if (or.top < 0 || or.bottom < 0 || or.bottom <= or.top) {
                hasInvalidOutline = true;
                detailStr.append(" invalidOutline:(").append(or.top).append(",")
                        .append(or.bottom).append(")");
            }
        }
        if (hasInvalidOutline) {
            String rowKey = (anv instanceof ExpandableNotificationRow)
                    ? ((ExpandableNotificationRow) anv).getKey()
                    : ev.toString();
            pw.print(" [!] Animating INVALID OUTLINE: " + rowKey);
            pw.print(" appearFraction: " + String.format(Locale.US, "%.3f",
                    anv.getAppearAnimationFraction()));
            pw.print(detailStr);
            pw.println();
        }
    }

    public void dump(PrintWriter pwOriginal, String[] args) {
        IndentingPrintWriter pw = DumpUtilsKt.asIndenting(pwOriginal);
        final long elapsedRealtime = SystemClock.elapsedRealtime();
        pw.println("Internal state:");
        DumpUtilsKt.withIncreasedIndent(pw, () -> {
            println(pw, "pulsing", mPulsing);
            println(pw, "expanded", mIsExpanded);
            println(pw, "headsUpPinned", mInHeadsUpPinnedMode);
            println(pw, "roundedRectClipping", mShouldUseRoundedRectClipping);
            println(pw, "negativeRoundedRectClipping", mShouldUseNegativeRoundedRectClipping);
            println(pw, "qsClipDismiss", mDismissUsingRowTranslationX);
            println(pw, "visibility", visibilityString(getVisibility()));
            println(pw, "alpha", getAlpha());
            println(pw, "suppressChildrenMeasureLayout", mSuppressChildrenMeasureAndLayout);
            println(pw, "scrollY", mAmbientState.getScrollY());
            println(pw, "showShelfOnly", mShouldShowShelfOnly);
            println(pw, "hideAmount", mAmbientState.getHideAmount());
            println(pw, "ambientStateSwipingUp", mAmbientState.isSwipingUp());
            println(pw, "maxDisplayedNotifications", mMaxDisplayedNotifications);
            println(pw, "intrinsicPadding", mIntrinsicPadding);
            println(pw, "bottomPadding", mBottomPadding);
            dumpRoundedRectClipping(pw);
            println(pw, "requestedClipBounds", mRequestedClipBounds);
            println(pw, "isClipped", mIsClipped);
            println(pw, "translationX", getTranslationX());
            println(pw, "translationY", getTranslationY());
            println(pw, "translationZ", getTranslationZ());
            println(pw, "skinnyNotifsInLandscape", mSkinnyNotifsInLandscape);
            println(pw, "minimumPaddings", mMinimumPaddings);
            println(pw, "qsTilePadding", mQsTilePadding);
            println(pw, "sidePaddings", mSidePaddings);
            println(pw, "elapsedRealtime", elapsedRealtime);
            println(pw, "lastInitView", mLastInitViewDumpString);
            println(pw, "lastInitViewElapsedRealtime", mLastInitViewElapsedRealtime);
            println(pw, "lastInitViewMillisAgo", elapsedRealtime - mLastInitViewElapsedRealtime);
            println(pw, "shouldUseSplitNotificationShade", mShouldUseSplitNotificationShade);
            println(pw, "lastUpdateSidePadding", mLastUpdateSidePaddingDumpString);
            println(pw, "lastUpdateSidePaddingElapsedRealtime",
                    mLastUpdateSidePaddingElapsedRealtime);
            println(pw, "lastUpdateSidePaddingMillisAgo",
                    elapsedRealtime - mLastUpdateSidePaddingElapsedRealtime);
            println(pw, "isSmallLandscapeLockscreenEnabled", mIsSmallLandscapeLockscreenEnabled);
            println(pw, "isAnimating", isCurrentlyAnimating());
            mNotificationStackSizeCalculator.dump(pw, args);
            mScrollViewFields.dump(pw);
            if (!SceneContainerFlag.isEnabled()) {
                // fields which will be removed with SceneContainer
                println(pw, "intrinsicContentHeight", getIntrinsicContentHeight());
                println(pw, "contentHeight", getContentHeight());
                println(pw, "topPadding", getTopPadding());
                println(pw, "maxTopPadding", getMaxTopPadding());
                println(pw, "qsExpandFraction", getQsExpansionFraction());
            }
        });
        pw.println();
        pw.println("Contents:");
        DumpUtilsKt.withIncreasedIndent(
                pw,
                () -> {
                    int childCount = getChildCount();
                    pw.println("Number of children: " + childCount);
                    pw.println();

                    for (int i = 0; i < childCount; i++) {
                        ExpandableView child = getChildAtIndex(i);
                        pw.println();
                        verifyOutline(pw, child);
                        child.dump(pw, args);
                        pw.println();
                    }
                    int transientViewCount = getTransientViewCount();
                    pw.println("Transient Views: " + transientViewCount);
                    for (int i = 0; i < transientViewCount; i++) {
                        ExpandableView child = (ExpandableView) getTransientView(i);
                        child.dump(pw, args);
                    }
                    View swipedView = mSwipeHelper.getSwipedView();
                    pw.println("Swiped view: " + swipedView);
                    if (swipedView instanceof ExpandableView expandableView) {
                        expandableView.dump(pw, args);
                    }
                });
    }

    private void dumpRoundedRectClipping(IndentingPrintWriter pw) {
        pw.append("roundedRectClipping{l=").print(mRoundedRectClippingLeft);
        pw.append(" t=").print(mRoundedRectClippingTop);
        pw.append(" r=").print(mRoundedRectClippingRight);
        pw.append(" b=").print(mRoundedRectClippingBottom);
        pw.append(" +y=").print(mRoundedRectClippingYTranslation);
        pw.append("} topRadius=").print(mRoundedClipCornerRadii[0]);
        pw.append(" bottomRadius=").println(mRoundedClipCornerRadii[4]);
    }

    public boolean isFullyHidden() {
        return mAmbientState.isFullyHidden();
    }

    /**
     * Add a listener whenever the expanded height changes. The first value passed as an
     * argument is the expanded height and the second one is the appearFraction.
     *
     * @param listener the listener to notify.
     */
    public void addOnExpandedHeightChangedListener(BiConsumer<Float, Float> listener) {
        mExpandedHeightListeners.add(listener);
    }

    /**
     * Stop a listener from listening to the expandedHeight.
     */
    public void removeOnExpandedHeightChangedListener(BiConsumer<Float, Float> listener) {
        mExpandedHeightListeners.remove(listener);
    }

    void setHeadsUpAppearanceController(
            HeadsUpAppearanceController headsUpAppearanceController) {
        mHeadsUpAppearanceController = headsUpAppearanceController;
    }

    @VisibleForTesting
    public boolean isVisible(View child) {
        boolean hasClipBounds = child.getClipBounds(mTmpRect);
        return child.getVisibility() == View.VISIBLE
                && (!hasClipBounds || mTmpRect.height() > 0);
    }

    public boolean isVisibleOrIsVisibleInShelf(ExpandableNotificationRow row) {
        return isVisible(row)
                || (NotificationBundleUi.isEnabled()
                && mShelf.getVisibility() != GONE && row.isInShelf());
    }

    /** Whether the group is expanded to show the child notifications, and they are visible. */
    private boolean areChildrenVisible(ExpandableNotificationRow parent) {
        List<ExpandableNotificationRow> children = parent.getAttachedChildren();
        return isVisible(parent)
                && children != null
                && parent.areChildrenExpanded();
    }

    // Similar to #getRowsToDismissInBackend, but filters for visible views.
    private ArrayList<View> getVisibleViewsToAnimateAway(@SelectedRows int selection,
            boolean hideSilentSection) {
        final int viewCount = getChildCount();
        final ArrayList<View> viewsToHide = new ArrayList<>(viewCount);

        for (int i = 0; i < viewCount; i++) {
            final View view = getChildAt(i);

            if (view instanceof SectionHeaderView) {
                // The only SectionHeaderView we have is the silent section header.
                if (hideSilentSection) {
                    viewsToHide.add(view);
                }
            }

            if (view instanceof ExpandableNotificationRow parent) {
                if (isVisible(parent) && includeChildInClearAll(parent, selection)) {
                    viewsToHide.add(parent);
                }

                if (areChildrenVisible(parent)) {
                    for (ExpandableNotificationRow child : parent.getAttachedChildren()) {
                        if (isVisible(child) && includeChildInClearAll(child, selection)) {
                            viewsToHide.add(child);
                        }
                    }
                }
            }
        }
        return viewsToHide;
    }

    private ArrayList<ExpandableNotificationRow> getRowsToDismissInBackend(
            @SelectedRows int selection) {
        final int childCount = getChildCount();
        final ArrayList<ExpandableNotificationRow> viewsToRemove = new ArrayList<>(childCount);

        for (int i = 0; i < childCount; i++) {
            final View view = getChildAt(i);
            if (!(view instanceof ExpandableNotificationRow parent)) {
                continue;
            }
            if (includeChildInClearAll(parent, selection)) {
                viewsToRemove.add(parent);
            }
            List<ExpandableNotificationRow> children = parent.getAttachedChildren();
            if (isVisibleOrIsVisibleInShelf(parent) && children != null) {
                for (ExpandableNotificationRow child : children) {
                    if (includeChildInClearAll(parent, selection)) {
                        viewsToRemove.add(child);
                    }
                }
            }
        }
        return viewsToRemove;
    }

    /** Clear all clearable notifications when the user requests it. */
    public void clearAllNotifications(boolean hideSilentSection) {
        clearNotifications(ROWS_ALL, /* closeShade = */ true, hideSilentSection);
    }

    /** Clear all clearable silent notifications when the user requests it. */
    public void clearSilentNotifications(boolean closeShade,
            boolean hideSilentSection) {
        clearNotifications(ROWS_GENTLE, closeShade, hideSilentSection);
    }

    /**
     * Collects a list of visible rows, and animates them away in a staggered fashion as if they
     * were dismissed. Notifications are dismissed in the backend via onClearAllAnimationsEnd.
     */
    void clearNotifications(@SelectedRows int selection, boolean closeShade,
            boolean hideSilentSection) {
        // Animate-swipe all dismissable notifications, then animate the shade closed
        final ArrayList<View> viewsToAnimateAway = getVisibleViewsToAnimateAway(selection,
                hideSilentSection);
        final ArrayList<ExpandableNotificationRow> rowsToDismissInBackend =
                getRowsToDismissInBackend(selection);
        if (mClearAllListener != null) {
            mClearAllListener.onClearAll(selection);
        }
        final Consumer<Boolean> dismissInBackend = (cancelled) -> {
            if (cancelled) {
                post(() -> onClearAllAnimationsEnd(rowsToDismissInBackend, selection));
            } else {
                onClearAllAnimationsEnd(rowsToDismissInBackend, selection);
            }
        };
        if (viewsToAnimateAway.isEmpty()) {
            dismissInBackend.accept(true);
            return;
        }
        // Disable normal animations
        setClearAllInProgress(true);
        mShadeNeedsToClose = closeShade;

        InteractionJankMonitor.getInstance().begin(this, CUJ_SHADE_CLEAR_ALL);
        // Decrease the delay for every row we animate to give the sense of
        // accelerating the swipes
        final int rowDelayDecrement = 5;
        int currentDelay = 60;
        int totalDelay = 0;
        final int numItems = viewsToAnimateAway.size();
        for (int i = numItems - 1; i >= 0; i--) {
            View view = viewsToAnimateAway.get(i);
            Consumer<Boolean> endRunnable = null;
            if (i == 0) {
                endRunnable = dismissInBackend;
            }
            dismissViewAnimated(view, endRunnable, totalDelay, ANIMATION_DURATION_SWIPE);
            currentDelay = Math.max(30, currentDelay - rowDelayDecrement);
            totalDelay += currentDelay;
        }
    }

    private boolean includeChildInClearAll(
            ExpandableNotificationRow row,
            @SelectedRows int selection) {
        return canChildBeCleared(row) && matchesSelection(row, selection);
    }

    /**
     * Updates expanded, dimmed and locked states of notification rows.
     */
    public void onUpdateRowStates() {

        // The following views will be moved to the end of mStackScroller. This counter represents
        // the offset from the last child. Initialized to 1 for the very last position. It is post-
        // incremented in the following "changeViewPosition" calls so that its value is correct for
        // subsequent calls.
        int offsetFromEnd = 1;
        changeViewPosition(mFooterView, getChildCount() - offsetFromEnd++);
        changeViewPosition(mEmptyShadeView, getChildCount() - offsetFromEnd++);

        // No post-increment for this call because it is the last one. Make sure to add one if
        // another "changeViewPosition" call is ever added.
        changeViewPosition(mShelf,
                getChildCount() - offsetFromEnd);
    }

    /**
     * Set how far the wake up is when waking up from pulsing. This is a height and will adjust the
     * notification positions accordingly.
     *
     * @param height the new wake up height
     * @return the overflow how much the height is further than he lowest notification
     */
    public float setPulseHeight(float height) {
        float overflow;
        mAmbientState.setPulseHeight(height);
        if (mKeyguardBypassEnabled) {
            notifyAppearChangedListeners();
            overflow = Math.max(0, height - getIntrinsicPadding());
        } else {
            overflow = Math.max(0, height
                    - mAmbientState.getInnerHeight(true /* ignorePulseHeight */));
        }
        requestChildrenUpdate();
        return overflow;
    }

    public float getPulseHeight() {
        return mAmbientState.getPulseHeight();
    }

    /**
     * Set the amount how much we're dozing. This is different from how hidden the shade is, when
     * the notification is pulsing.
     */
    public void setDozeAmount(float dozeAmount) {
        mAmbientState.setDozeAmount(dozeAmount);
        updateStackPosition();
        requestChildrenUpdate();
    }

    public boolean isFullyAwake() {
        return mAmbientState.isFullyAwake();
    }

    public void wakeUpFromPulse() {
        setPulseHeight(getWakeUpHeight());
        // Let's place the hidden views at the end of the pulsing notification to make sure we have
        // a smooth animation
        boolean firstVisibleView = true;
        float wakeUplocation = -1f;
        int childCount = getChildCount();
        for (int i = 0; i < childCount; i++) {
            ExpandableView view = getChildAtIndex(i);
            if (view.getVisibility() == View.GONE) {
                continue;
            }
            boolean isShelf = view == mShelf;
            if (!(view instanceof ExpandableNotificationRow) && !isShelf) {
                continue;
            }
            if (view.getVisibility() == View.VISIBLE && !isShelf) {
                if (firstVisibleView) {
                    firstVisibleView = false;
                    wakeUplocation = view.getTranslationY()
                            + view.getActualHeight() - mShelf.getIntrinsicHeight();
                }
            } else if (!firstVisibleView) {
                if (physicalNotificationMovement()) {
                    PhysicsPropertyAnimator.setProperty(view, PhysicsPropertyAnimator.Y_TRANSLATION,
                            wakeUplocation);
                } else {
                    view.setTranslationY(wakeUplocation);
                }
            }
        }
    }

    public void setOnPulseHeightChangedListener(Runnable listener) {
        mAmbientState.setOnPulseHeightChangedListener(listener);
    }

    public float calculateAppearFractionBypass() {
        SceneContainerFlag.assertInLegacyMode();
        return calculateAppearFractionBypassInternal();
    }

    private float calculateAppearFractionBypassInternal() {
        float pulseHeight = getPulseHeight();
        // The total distance required to fully reveal the header
        float totalDistance = getIntrinsicPadding();
        return MathUtils.smoothStep(0, totalDistance, pulseHeight);
    }

    public void setController(
            NotificationStackScrollLayoutController notificationStackScrollLayoutController) {
        mController = notificationStackScrollLayoutController;
        mController.getNotificationRoundnessManager().setAnimatedChildren(mChildrenToAddAnimated);
    }

    void addSwipedOutView(View v) {
        logAddSwipedOutView(v);
        mSwipedOutViews.add(v);
    }

    private void logAddSwipedOutView(View v) {
        if (mLogger != null && v instanceof ExpandableNotificationRow row) {
            mLogger.logAddSwipedOutView(row.getLoggingKey(), mClearAllInProgress);
        }
    }

    void removeSwipedOutView(View v) {
        logRemoveSwipedOutView(v);
        mSwipedOutViews.remove(v);
    }

    private void logRemoveSwipedOutView(View v) {
        if (mLogger != null && v instanceof ExpandableNotificationRow row) {
            mLogger.logRemoveSwipedOutView(row.getLoggingKey(), mClearAllInProgress);
        }
    }


    void onSwipeBegin(View viewSwiped) {
        if (!(viewSwiped instanceof ExpandableNotificationRow)) {
            return;
        }
        mSectionsManager.updateFirstAndLastViewsForAllSections(
                mSections,
                getChildrenWithBackground()
        );

        if (!magneticNotificationSwipes()) {
            RoundableTargets targets = mController
                    .getNotificationTargetsHelper()
                    .findRoundableTargets(
                            (ExpandableNotificationRow) viewSwiped,
                            this,
                            mSectionsManager);

            mController.getNotificationRoundnessManager()
                    .setViewsAffectedBySwipe(
                            targets.getBefore(),
                            targets.getSwiped(),
                            targets.getAfter());
            mController.getNotificationRoundnessManager()
                    .setRoundnessForAffectedViews(/* roundness */ 1f);
        }

        updateFirstAndLastBackgroundViews();
        requestDisallowInterceptTouchEvent(true);
        updateContinuousShadowDrawing();
        requestChildrenUpdate();
    }

    void onSwipeEnd() {
        updateFirstAndLastBackgroundViews();
        if (!magneticNotificationSwipes()) {
            mController.getNotificationRoundnessManager()
                    .setViewsAffectedBySwipe(null, null, null);
        }
        // Round bottom corners for notification right before shelf.
        mShelf.updateAppearance();
    }

    /**
     * @param topHeadsUpRow the first headsUp row in z-order.
     */
    public void setTopHeadsUpRow(@Nullable ExpandableNotificationRow topHeadsUpRow) {
        mTopHeadsUpRow = topHeadsUpRow;
        notifyHeadsUpHeightChangedListeners();
    }

    public boolean getIsExpanded() {
        return mIsExpanded;
    }

    boolean getOnlyScrollingInThisMotion() {
        return mOnlyScrollingInThisMotion;
    }

    ExpandHelper getExpandHelper() {
        return mExpandHelper;
    }

    boolean isExpandingNotification() {
        return mExpandingNotification;
    }

    boolean getDisallowScrollingInThisMotion() {
        return mDisallowScrollingInThisMotion;
    }

    public boolean isBeingDragged() {
        return mIsBeingDragged;
    }

    boolean getExpandedInThisMotion() {
        return mExpandedInThisMotion;
    }

    boolean getDisallowDismissInThisMotion() {
        return mDisallowDismissInThisMotion;
    }

    void setCheckForLeaveBehind(boolean checkForLeaveBehind) {
        mCheckForLeavebehind = checkForLeaveBehind;
    }

    void setTouchHandler(NotificationStackScrollLayoutController.TouchHandler touchHandler) {
        mTouchHandler = touchHandler;
    }

    boolean getCheckSnoozeLeaveBehind() {
        return mCheckForLeavebehind;
    }

    void setClearAllListener(ClearAllListener listener) {
        mClearAllListener = listener;
    }

    void setClearAllAnimationListener(ClearAllAnimationListener clearAllAnimationListener) {
        mClearAllAnimationListener = clearAllAnimationListener;
    }

    public void setHighPriorityBeforeSpeedBump(boolean highPriorityBeforeSpeedBump) {
        mHighPriorityBeforeSpeedBump = highPriorityBeforeSpeedBump;
    }

    void setClearAllFinishedWhilePanelExpandedRunnable(Runnable runnable) {
        mClearAllFinishedWhilePanelExpandedRunnable = runnable;
    }

    /**
     * Sets the extra top inset for the full shade transition. This moves notifications down
     * during the drag down.
     */
    public void setExtraTopInsetForFullShadeTransition(float inset) {
        mExtraTopInsetForFullShadeTransition = inset;
        updateStackPosition();
        requestChildrenUpdate();
    }

    /**
     * @param fraction Fraction of the lockscreen to shade transition. 0f for all other states.
     *                 Once the lockscreen to shade transition completes and the shade is 100% open
     *                 LockscreenShadeTransitionController resets fraction to 0
     *                 where it remains until the next lockscreen-to-shade transition.
     */
    public void setFractionToShade(float fraction) {
        mAmbientState.setFractionToShade(fraction);
        updateContentHeight();  // Recompute stack height with different section gap.
        requestChildrenUpdate();
    }

    /**
     * Set a listener to when scrolling changes.
     */
    public void setOnScrollListener(Consumer<Integer> listener) {
        SceneContainerFlag.assertInLegacyMode();
        mScrollListener = listener;
    }

    /** Sets rounded rect where the view is allowed to draw. */
    @Override
    public void setClippingShape(@Nullable ShadeScrimShape shape) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        if (Objects.equals(mScrollViewFields.clippingShape, shape)) return;
        mScrollViewFields.clippingShape = shape;
        mShouldUseRoundedRectClipping = shape != null;
        mRoundedClipPath.reset();
        if (shape != null) {
            ShadeScrimBounds bounds = shape.getBounds();
            mRoundedRectClippingLeft = (int) bounds.getLeft();
            mRoundedRectClippingTop = (int) bounds.getTop();
            mRoundedRectClippingRight = (int) bounds.getRight();
            mRoundedRectClippingBottom = (int) bounds.getBottom();
            mRoundedClipCornerRadii[0] = shape.getTopRadius();
            mRoundedClipCornerRadii[1] = shape.getTopRadius();
            mRoundedClipCornerRadii[2] = shape.getTopRadius();
            mRoundedClipCornerRadii[3] = shape.getTopRadius();
            mRoundedClipCornerRadii[4] = shape.getBottomRadius();
            mRoundedClipCornerRadii[5] = shape.getBottomRadius();
            mRoundedClipCornerRadii[6] = shape.getBottomRadius();
            mRoundedClipCornerRadii[7] = shape.getBottomRadius();
            mRoundedClipPath.addRoundRect(
                    bounds.getLeft(), bounds.getTop(), bounds.getRight(), bounds.getBottom(),
                    mRoundedClipCornerRadii, Path.Direction.CW);
        }
        invalidate();
    }

    @Override
    public void setNegativeClippingShape(@Nullable ShadeScrimShape shape) {
        if (SceneContainerFlag.isUnexpectedlyInLegacyMode()) return;
        if (Objects.equals(mScrollViewFields.negativeClippingShape, shape)) return;
        mScrollViewFields.negativeClippingShape = shape;
        mShouldUseNegativeRoundedRectClipping = shape != null;
        mNegativeRoundedClipPath.reset();
        if (shape != null) {
            ShadeScrimBounds bounds = shape.getBounds();
            float topRadius = shape.getTopRadius();
            float bottomRadius = shape.getBottomRadius();
            mNegativeRoundedClipPath.addRoundRect(
                    bounds.getLeft(), bounds.getTop(), bounds.getRight(), bounds.getBottom(),
                    new float[]{topRadius, topRadius, topRadius, topRadius,
                            bottomRadius, bottomRadius, bottomRadius, bottomRadius},
                    Path.Direction.CW);
        }
        invalidate();
    }

    @Override
    public void setBlurRadius(float blurRadius) {
        if (mBlurRadius != blurRadius) {
            mBlurRadius = blurRadius;
            updateBlurEffect();
            invalidate();
        }
    }

    private void updateBlurEffect() {
        if (mBlurRadius > 0) {
            mBlurEffect =
                    RenderEffect.createBlurEffect(mBlurRadius, mBlurRadius, Shader.TileMode.CLAMP);
            spewLog("Setting up blur RenderEffect for NotificationStackScrollLayout");
        } else {
            spewLog("Clearing the blur RenderEffect setup for NotificationStackScrollLayout");
            mBlurEffect = null;
        }
    }

    /**
     * Set rounded rect clipping bounds on this view.
     */
    public void setRoundedClippingBounds(int left, int top, int right, int bottom, int topRadius,
                                         int bottomRadius) {
        SceneContainerFlag.assertInLegacyMode();
        if (mRoundedRectClippingLeft == left && mRoundedRectClippingRight == right
                && mRoundedRectClippingBottom == bottom && mRoundedRectClippingTop == top
                && mRoundedClipCornerRadii[0] == topRadius
                && mRoundedClipCornerRadii[5] == bottomRadius) {
            return;
        }
        mRoundedRectClippingLeft = left;
        mRoundedRectClippingTop = top;
        mRoundedRectClippingBottom = bottom;
        mRoundedRectClippingRight = right;
        mRoundedClipCornerRadii[0] = topRadius;
        mRoundedClipCornerRadii[1] = topRadius;
        mRoundedClipCornerRadii[2] = topRadius;
        mRoundedClipCornerRadii[3] = topRadius;
        mRoundedClipCornerRadii[4] = bottomRadius;
        mRoundedClipCornerRadii[5] = bottomRadius;
        mRoundedClipCornerRadii[6] = bottomRadius;
        mRoundedClipCornerRadii[7] = bottomRadius;
        updateRoundedClipPath();
    }

    // see b/288553572
    private void setRoundingClippingYTranslation(int yTranslation) {
        SceneContainerFlag.assertInLegacyMode();
        if (mRoundedRectClippingYTranslation == yTranslation) {
            return;
        }
        mRoundedRectClippingYTranslation = yTranslation;
        updateRoundedClipPath();
    }

    private void updateRoundedClipPath() {
        SceneContainerFlag.assertInLegacyMode();
        mRoundedClipPath.reset();
        mRoundedClipPath.addRoundRect(
                mRoundedRectClippingLeft,
                mRoundedRectClippingTop + mRoundedRectClippingYTranslation,
                mRoundedRectClippingRight,
                mRoundedRectClippingBottom + mRoundedRectClippingYTranslation,
                mRoundedClipCornerRadii, Path.Direction.CW);
        if (mShouldUseRoundedRectClipping) {
            invalidate();
        }
    }

    @VisibleForTesting
    void updateSplitNotificationShade() {
        boolean split = mSplitShadeStateController.shouldUseSplitNotificationShade(getResources());
        if (split != mShouldUseSplitNotificationShade) {
            mShouldUseSplitNotificationShade = split;
            mShouldSkipTopPaddingAnimationAfterFold = true;
            mAmbientState.setUseSplitShade(split);
            updateDismissBehavior();
            updateUseRoundedRectClipping();
            requestLayout();
        }
    }

    private void updateDismissBehavior() {
        // On the split keyguard, dismissing with clipping without a visual boundary looks odd,
        // so let's use the content dismiss behavior instead.
        boolean dismissUsingRowTranslationX = !mShouldUseSplitNotificationShade
                || (mStatusBarState != StatusBarState.KEYGUARD && mIsExpanded);
        if (mDismissUsingRowTranslationX != dismissUsingRowTranslationX) {
            mDismissUsingRowTranslationX = dismissUsingRowTranslationX;
            for (int i = 0; i < getChildCount(); i++) {
                View child = getChildAt(i);
                if (child instanceof ExpandableNotificationRow) {
                    ((ExpandableNotificationRow) child).setDismissUsingRowTranslationX(
                            dismissUsingRowTranslationX, /* force= */ false);
                }
            }
        }
    }

    /**
     * Set if we're launching a notification right now.
     */
    private void setLaunchingNotification(boolean launching) {
        if (launching == mLaunchingNotification) {
            return;
        }
        mLaunchingNotification = launching;
        mLaunchingNotificationNeedsToBeClipped = mLaunchAnimationParams != null
                && (mLaunchAnimationParams.getStartRoundedTopClipping() > 0
                || mLaunchAnimationParams.getParentStartRoundedTopClipping() > 0);
        if (!mLaunchingNotificationNeedsToBeClipped || !mLaunchingNotification) {
            mLaunchedNotificationClipPath.reset();
        }
        // When launching notifications, we're clipping the children individually instead of in
        // dispatchDraw
        invalidate();
    }

    /**
     * Should we use rounded rect clipping
     */
    private void updateUseRoundedRectClipping() {
        if (SceneContainerFlag.isEnabled()) return;
        // We don't want to clip notifications when QS is expanded, because incoming heads up on
        // the bottom would be clipped otherwise
        boolean qsAllowsClipping =
                getQsExpansionFraction() < 0.5f || mShouldUseSplitNotificationShade;
        boolean clip = mIsExpanded && qsAllowsClipping;
        if (clip != mShouldUseRoundedRectClipping) {
            mShouldUseRoundedRectClipping = clip;
            invalidate();
        }
    }

    /**
     * Update the clip path for launched notifications in case they were originally clipped
     */
    private void updateLaunchedNotificationClipPath() {
        if (!mLaunchingNotificationNeedsToBeClipped || !mLaunchingNotification
                || mExpandingNotificationRow == null) {
            return;
        }
        int[] absoluteCoords = new int[2];
        getLocationOnScreen(absoluteCoords);

        int left = Math.min(mLaunchAnimationParams.getLeft() - absoluteCoords[0],
                mRoundedRectClippingLeft);
        int right = Math.max(mLaunchAnimationParams.getRight() - absoluteCoords[0],
                mRoundedRectClippingRight);
        int bottom = Math.max(mLaunchAnimationParams.getBottom() - absoluteCoords[1],
                mRoundedRectClippingBottom);
        float expandProgress = Interpolators.FAST_OUT_SLOW_IN.getInterpolation(
                mLaunchAnimationParams.getProgress(0,
                        NotificationTransitionAnimatorController.ANIMATION_DURATION_TOP_ROUNDING));
        int top = (int) Math.min(MathUtils.lerp(mRoundedRectClippingTop,
                        mLaunchAnimationParams.getTop() - absoluteCoords[1], expandProgress),
                mRoundedRectClippingTop);
        float topRadius = mLaunchAnimationParams.getTopCornerRadius();
        float bottomRadius = mLaunchAnimationParams.getBottomCornerRadius();
        mLaunchedNotificationRadii[0] = topRadius;
        mLaunchedNotificationRadii[1] = topRadius;
        mLaunchedNotificationRadii[2] = topRadius;
        mLaunchedNotificationRadii[3] = topRadius;
        mLaunchedNotificationRadii[4] = bottomRadius;
        mLaunchedNotificationRadii[5] = bottomRadius;
        mLaunchedNotificationRadii[6] = bottomRadius;
        mLaunchedNotificationRadii[7] = bottomRadius;
        mLaunchedNotificationClipPath.reset();
        mLaunchedNotificationClipPath.addRoundRect(left, top, right, bottom,
                mLaunchedNotificationRadii, Path.Direction.CW);
        // Offset into notification clip coordinates instead of parent ones.
        // This is needed since the notification changes in translationZ, where clipping via
        // canvas dispatching won't work.
        ExpandableNotificationRow expandingRow = mExpandingNotificationRow;
        if (expandingRow.getNotificationParent() != null) {
            expandingRow = expandingRow.getNotificationParent();
        }
        mLaunchedNotificationClipPath.offset(
                -expandingRow.getLeft() - expandingRow.getTranslationX(),
                -expandingRow.getTop() - expandingRow.getTranslationY());
        expandingRow.setExpandingClipPath(mLaunchedNotificationClipPath);
        if (mShouldUseRoundedRectClipping) {
            invalidate();
        }
    }

    @Override
    protected void dispatchDraw(@NonNull Canvas canvas) {
        if (mBlurEffect != null) {
            spewLog("Applying blur RenderEffect to NotificationStackScrollLayout");
            // reuse the cached RenderNode to blur
            mBlurNode.setPosition(0, 0, canvas.getWidth(), canvas.getHeight());
            mBlurNode.setRenderEffect(mBlurEffect);
            Canvas blurCanvas = mBlurNode.beginRecording();
            // draw all the children (except HUNs) on the blurred canvas
            super.dispatchDraw(blurCanvas);
            mBlurNode.endRecording();
            // apply clipping to the canvas
            int saveCount = canvas.save();
            applyClipToCanvas(canvas);
            // draw the blurred content to the clipped canvas
            canvas.drawRenderNode(mBlurNode);
            // restore the canvas, so it doesn't clip anymore
            canvas.restoreToCount(saveCount);
            // draw the children that were left out during the dispatchDraw phase
            for (int i = 0; i < getChildCount(); i++) {
                // TODO(b/388469101) draw these children in z-order
                ExpandableView child = getChildAtIndex(i);
                if (shouldSkipBlurForChild(child)) {
                    super.drawChild(canvas, child, getDrawingTime());
                }
            }
        } else {
            if (!mLaunchingNotification) {
                // When launching notifications, we're clipping the children individually instead
                //  of in dispatchDraw
                applyClipToCanvas(canvas);
            }
            super.dispatchDraw(canvas);
        }
    }

    private void applyClipToCanvas(Canvas canvas) {
        if (mShouldUseRoundedRectClipping) {
            // clip by the positive path if it is defined
            canvas.clipPath(mRoundedClipPath);
        }
        if (mShouldUseNegativeRoundedRectClipping) {
            // subtract the negative path if it is defined
            canvas.clipOutPath(mNegativeRoundedClipPath);
        }
    }

    @Override
    protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
        boolean shouldUseClipping =
                mShouldUseRoundedRectClipping || mShouldUseNegativeRoundedRectClipping;
        if (mBlurEffect != null) {
            if (shouldSkipBlurForChild(child)) {
                // skip drawing this child during the regular dispatchDraw pass
                return false;
            } else {
                // draw the child as if nothing happened, non-blurred elements shouldn't be
                // affected by clipping either
                return super.drawChild(canvas, child, drawingTime);
            }
        } else if (mLaunchingNotification && shouldUseClipping) {
            // Let's clip children individually during notification launch
            canvas.save();
            ExpandableView expandableView = (ExpandableView) child;
            Path clipPath;
            Path clipOutPath;
            if (expandableView.isExpandAnimationRunning()
                    || ((ExpandableView) child).hasExpandingChild()) {
                // When launching the notification, it is not clipped by this layout, but by the
                // view itself. This is because the view is Translating in Z, where this clipPath
                // wouldn't apply.
                clipPath = null;
                clipOutPath = null;
            } else {
                clipPath = mRoundedClipPath;
                clipOutPath = mNegativeRoundedClipPath;
            }
            if (mShouldUseRoundedRectClipping && clipPath != null) {
                canvas.clipPath(clipPath);
            }
            if (mShouldUseNegativeRoundedRectClipping && clipOutPath != null) {
                canvas.clipOutPath(clipOutPath);
            }
            boolean result = super.drawChild(canvas, child, drawingTime);
            canvas.restore();
            return result;
        } else {
            return super.drawChild(canvas, child, drawingTime);
        }
    }

    private boolean shouldSkipBlurForChild(View child) {
        if (child instanceof ExpandableView row) {
            return row.isHeadsUpState();
        } else {
            return false;
        }
    }

    /**
     * Calculate the total translation needed when dismissing.
     */
    public float getTotalTranslationLength(View animView) {
        if (!mDismissUsingRowTranslationX) {
            return animView.getMeasuredWidth();
        }
        float notificationWidth = animView.getMeasuredWidth();
        int containerWidth = getMeasuredWidth();
        float padding = (containerWidth - notificationWidth) / 2.0f;
        return containerWidth - padding;
    }

    /**
     * @return the start location where we start clipping notifications.
     */
    public int getTopClippingStartLocation() {
        return mIsExpanded ? mQsScrollBoundaryPosition : 0;
    }

    /**
     * Request an animation whenever the toppadding changes next
     */
    public void animateNextTopPaddingChange() {
        mAnimateNextTopPaddingChange = true;
    }

    /**
     * Sets a {@link StackStateLogger} which is notified as the {@link StackStateAnimator} updates
     * the views.
     */
    protected void setStackStateLogger(StackStateLogger logger) {
        mStateAnimator.setLogger(logger);
    }

    /**
     * A listener that is notified when the empty space below the notifications is clicked on
     */
    public interface OnEmptySpaceClickListener {
        void onEmptySpaceClicked(float x, float y);
    }

    /**
     * A listener that gets notified when the overscroll at the top has changed.
     */
    public interface OnOverscrollTopChangedListener {

        /**
         * Notifies a listener that the overscroll has changed.
         *
         * @param amount         the amount of overscroll, in pixels
         * @param isRubberbanded if true, this is a rubberbanded overscroll; if false, this is an
         *                       unrubberbanded motion to directly expand overscroll view (e.g
         *                       expand
         *                       QS)
         */
        void onOverscrollTopChanged(float amount, boolean isRubberbanded);

        /**
         * Notify a listener that the scroller wants to escape from the scrolling motion and
         * start a fling animation to the expanded or collapsed overscroll view (e.g expand the QS)
         *
         * @param velocity The velocity that the Scroller had when over flinging
         * @param open     Should the fling open or close the overscroll view.
         */
        void flingTopOverscroll(float velocity, boolean open);
    }

    /**
     * A listener that is notified when some ExpandableNotificationRow locations might have changed.
     */
    public interface OnNotificationLocationsChangedListener {
        /**
         * Called when the location of ExpandableNotificationRows might have changed.
         *
         * @param locations mapping of Notification keys to locations.
         */
        void onChildLocationsChanged(Callable<Map<String, Integer>> locations);
    }

    private void updateSpeedBumpIndex() {
        mSpeedBumpIndexDirty = true;
    }

    private void resetAllSwipeState() {
        Trace.beginSection("NSSL.resetAllSwipeState()");
        mSwipeHelper.resetTouchState();
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            mSwipeHelper.forceResetSwipeState(child);
            if (child instanceof ExpandableNotificationRow childRow) {
                List<ExpandableNotificationRow> grandchildren = childRow.getAttachedChildren();
                if (grandchildren != null) {
                    for (ExpandableNotificationRow grandchild : grandchildren) {
                        mSwipeHelper.forceResetSwipeState(grandchild);
                    }
                }
            }
        }
        updateContinuousShadowDrawing();
        Trace.endSection();
    }

    void updateContinuousShadowDrawing() {
        boolean continuousShadowUpdate = mAnimationRunning
                || mSwipeHelper.isSwiping();
        if (continuousShadowUpdate != mContinuousShadowUpdate) {
            if (continuousShadowUpdate) {
                getViewTreeObserver().addOnPreDrawListener(mShadowUpdater);
            } else {
                getViewTreeObserver().removeOnPreDrawListener(mShadowUpdater);
            }
            mContinuousShadowUpdate = continuousShadowUpdate;
        }
    }

    private void resetExposedMenuView(boolean animate, boolean force) {
        mSwipeHelper.resetExposedMenuView(animate, force);
    }

    static boolean matchesSelection(
            ExpandableNotificationRow row,
            @SelectedRows int selection) {
        int bucket = NotificationBundleUi.isEnabled()
                ? row.getEntryAdapter().getSectionBucket()
                : row.getEntryLegacy().getBucket();
        switch (selection) {
            case ROWS_ALL:
                return true;
            case ROWS_HIGH_PRIORITY:
                return bucket < BUCKET_SILENT;
            case ROWS_GENTLE:
                if (NotificationBundleUi.isEnabled()) {
                    return bucket == BUCKET_SILENT
                            || bucket == BUCKET_PROMO
                            || bucket == BUCKET_RECS
                            || bucket == BUCKET_SOCIAL
                            || bucket == BUCKET_NEWS;
                } else {
                    return bucket == BUCKET_SILENT;
                }
            default:
                throw new IllegalArgumentException("Unknown selection: " + selection);
        }
    }

    static class AnimationEvent {

        static AnimationFilter[] FILTERS = new AnimationFilter[]{

                // ANIMATION_TYPE_ADD
                physicalNotificationMovement()
                        ? new AnimationFilter()
                                .animateAlpha()
                                .animateHeight()
                                .animateTopInset()
                                .animateY()
                                .animateZ()
                        : new AnimationFilter()
                                .animateAlpha()
                                .animateHeight()
                                .animateTopInset()
                                .animateY()
                                .animateZ()
                                .hasDelays(),

                // ANIMATION_TYPE_REMOVE
                physicalNotificationMovement()
                        ? new AnimationFilter()
                                .animateAlpha()
                                .animateHeight()
                                .animateTopInset()
                                .animateY()
                                .animateZ()
                        : new AnimationFilter()
                                .animateAlpha()
                                .animateHeight()
                                .animateTopInset()
                                .animateY()
                                .animateZ()
                                .hasDelays(),

                // ANIMATION_TYPE_REMOVE_SWIPED_OUT
                physicalNotificationMovement()
                        ? new AnimationFilter()
                                .animateHeight()
                                .animateTopInset()
                                .animateY()
                                .animateZ()
                        : new AnimationFilter()
                                .animateHeight()
                                .animateTopInset()
                                .animateY()
                                .animateZ()
                                .hasDelays(),

                // ANIMATION_TYPE_TOP_PADDING_CHANGED
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ(),

                // ANIMATION_TYPE_ACTIVATED_CHILD
                new AnimationFilter()
                        .animateZ(),

                // ANIMATION_TYPE_DIMMED
                new AnimationFilter(),

                // ANIMATION_TYPE_CHANGE_POSITION
                new AnimationFilter()
                        .animateAlpha() // maybe the children change positions
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ(),

                // ANIMATION_TYPE_GO_TO_FULL_SHADE
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ()
                        .hasDelays(),

                // ANIMATION_TYPE_HIDE_SENSITIVE
                new AnimationFilter()
                        .animateHideSensitive(),

                // ANIMATION_TYPE_VIEW_RESIZE
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ(),

                // ANIMATION_TYPE_GROUP_EXPANSION_CHANGED
                new AnimationFilter()
                        .animateAlpha()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ(),

                // ANIMATION_TYPE_HEADS_UP_APPEAR
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ(),

                // ANIMATION_TYPE_HEADS_UP_DISAPPEAR
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ()
                        .hasDelays(),

                // ANIMATION_TYPE_HEADS_UP_DISAPPEAR_CLICK
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ()
                        .hasDelays(),

                // ANIMATION_TYPE_HEADS_UP_OTHER
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ(),

                // ANIMATION_TYPE_EVERYTHING
                new AnimationFilter()
                        .animateAlpha()
                        .animateHideSensitive()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ(),

                // ANIMATION_TYPE_HEADS_UP_CYCLING_OUT
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ()
                        .hasDelays(),

                // ANIMATION_TYPE_HEADS_UP_CYCLING_IN
                new AnimationFilter()
                        .animateHeight()
                        .animateTopInset()
                        .animateY()
                        .animateZ()
                        .hasDelays(),
        };

        static int[] LENGTHS = new int[]{

                // ANIMATION_TYPE_ADD
                StackStateAnimator.ANIMATION_DURATION_APPEAR_DISAPPEAR,

                // ANIMATION_TYPE_REMOVE
                StackStateAnimator.ANIMATION_DURATION_APPEAR_DISAPPEAR,

                // ANIMATION_TYPE_REMOVE_SWIPED_OUT
                StackStateAnimator.ANIMATION_DURATION_STANDARD,

                // ANIMATION_TYPE_TOP_PADDING_CHANGED
                StackStateAnimator.ANIMATION_DURATION_STANDARD,

                // ANIMATION_TYPE_ACTIVATED_CHILD
                StackStateAnimator.ANIMATION_DURATION_DIMMED_ACTIVATED,

                // ANIMATION_TYPE_DIMMED
                StackStateAnimator.ANIMATION_DURATION_DIMMED_ACTIVATED,

                // ANIMATION_TYPE_CHANGE_POSITION
                StackStateAnimator.ANIMATION_DURATION_STANDARD,

                // ANIMATION_TYPE_GO_TO_FULL_SHADE
                StackStateAnimator.ANIMATION_DURATION_GO_TO_FULL_SHADE,

                // ANIMATION_TYPE_HIDE_SENSITIVE
                StackStateAnimator.ANIMATION_DURATION_STANDARD,

                // ANIMATION_TYPE_VIEW_RESIZE
                StackStateAnimator.ANIMATION_DURATION_STANDARD,

                // ANIMATION_TYPE_GROUP_EXPANSION_CHANGED
                StackStateAnimator.ANIMATION_DURATION_STANDARD,

                // ANIMATION_TYPE_HEADS_UP_APPEAR
                StackStateAnimator.ANIMATION_DURATION_HEADS_UP_APPEAR,

                // ANIMATION_TYPE_HEADS_UP_DISAPPEAR
                StackStateAnimator.ANIMATION_DURATION_HEADS_UP_DISAPPEAR,

                // ANIMATION_TYPE_HEADS_UP_DISAPPEAR_CLICK
                StackStateAnimator.ANIMATION_DURATION_HEADS_UP_DISAPPEAR,

                // ANIMATION_TYPE_HEADS_UP_OTHER
                StackStateAnimator.ANIMATION_DURATION_STANDARD,

                // ANIMATION_TYPE_EVERYTHING
                StackStateAnimator.ANIMATION_DURATION_STANDARD,

                // ANIMATION_TYPE_HEADS_UP_CYCLING_OUT
                StackStateAnimator.ANIMATION_DURATION_HEADS_UP_CYCLING,

                // ANIMATION_TYPE_HEADS_UP_CYCLING_IN
                StackStateAnimator.ANIMATION_DURATION_HEADS_UP_CYCLING,
        };

        static final int ANIMATION_TYPE_ADD = 0;
        static final int ANIMATION_TYPE_REMOVE = 1;
        static final int ANIMATION_TYPE_REMOVE_SWIPED_OUT = 2;
        static final int ANIMATION_TYPE_TOP_PADDING_CHANGED = 3;
        static final int ANIMATION_TYPE_ACTIVATED_CHILD = 4;
        static final int ANIMATION_TYPE_DIMMED = 5;
        static final int ANIMATION_TYPE_CHANGE_POSITION = 6;
        static final int ANIMATION_TYPE_GO_TO_FULL_SHADE = 7;
        static final int ANIMATION_TYPE_HIDE_SENSITIVE = 8;
        static final int ANIMATION_TYPE_VIEW_RESIZE = 9;
        static final int ANIMATION_TYPE_GROUP_EXPANSION_CHANGED = 10;
        static final int ANIMATION_TYPE_HEADS_UP_APPEAR = 11;
        static final int ANIMATION_TYPE_HEADS_UP_DISAPPEAR = 12;
        static final int ANIMATION_TYPE_HEADS_UP_DISAPPEAR_CLICK = 13;
        static final int ANIMATION_TYPE_HEADS_UP_OTHER = 14;
        static final int ANIMATION_TYPE_EVERYTHING = 15;
        static final int ANIMATION_TYPE_HEADS_UP_CYCLING_OUT = 16;
        static final int ANIMATION_TYPE_HEADS_UP_CYCLING_IN = 17;

        final long eventStartTime;
        final ExpandableView mChangingView;
        final int animationType;
        final AnimationFilter filter;
        final long length;
        View viewAfterChangingView;
        boolean headsUpFromBottom;
        boolean headsUpHasStatusBarChip;

        AnimationEvent(ExpandableView view, int type) {
            this(view, type, LENGTHS[type]);
        }

        AnimationEvent(ExpandableView view, int type, AnimationFilter filter) {
            this(view, type, LENGTHS[type], filter);
        }

        AnimationEvent(ExpandableView view, int type, long length) {
            this(view, type, length, FILTERS[type]);
        }

        AnimationEvent(ExpandableView view, int type, long length, AnimationFilter filter) {
            eventStartTime = AnimationUtils.currentAnimationTimeMillis();
            mChangingView = view;
            animationType = type;
            this.length = length;
            this.filter = filter;
        }

        /**
         * Combines the length of several animation events into a single value.
         *
         * @param events The events of the lengths to combine.
         * @return The combined length. Depending on the event types, this might be the maximum of
         * all events or the length of a specific event.
         */
        static long combineLength(ArrayList<AnimationEvent> events) {
            long length = 0;
            int size = events.size();
            for (int i = 0; i < size; i++) {
                AnimationEvent event = events.get(i);
                length = Math.max(length, event.length);
                if (event.animationType == ANIMATION_TYPE_GO_TO_FULL_SHADE) {
                    return event.length;
                }
            }
            return length;
        }
    }

    static boolean canChildBeDismissed(View v) {
        if (v instanceof ExpandableNotificationRow row) {
            return row.canExpandableViewBeDismissed();
        }
        return false;
    }

    static boolean canChildBeCleared(View v) {
        if (v instanceof ExpandableNotificationRow row) {
            if (row.areGutsExposed() || !row.hasFinishedInitialization()) {
                return false;
            }
            return row.canViewBeCleared();
        }
        return false;
    }

    // --------------------- NotificationEntryManager/NotifPipeline methods ------------------------

    void onEntryUpdated(NotificationEntry entry) {
        // If the row already exists, the user may have performed a dismiss action on the
        // notification. Since it's not clearable we should snap it back.
        if (entry.rowExists() && !entry.getSbn().isClearable()) {
            snapViewIfNeeded(entry);
        }
    }

    /**
     * Called after the animations for a "clear all notifications" action has ended.
     */
    private void onClearAllAnimationsEnd(
            List<ExpandableNotificationRow> viewsToRemove,
            @SelectedRows int selectedRows) {
        InteractionJankMonitor.getInstance().end(CUJ_SHADE_CLEAR_ALL);
        if (mClearAllAnimationListener != null) {
            mClearAllAnimationListener.onAnimationEnd(viewsToRemove, selectedRows);
        }
    }

    void resetCheckSnoozeLeavebehind() {
        setCheckForLeaveBehind(true);
    }

    private final HeadsUpTouchHelper.Callback mHeadsUpCallback = new HeadsUpTouchHelper.Callback() {
        @Override
        public ExpandableView getChildAtRawPosition(float touchX, float touchY) {
            return NotificationStackScrollLayout.this.getChildAtRawPosition(touchX, touchY);
        }

        @Override
        public boolean isExpanded() {
            return mIsExpanded;
        }

        @Override
        public Context getContext() {
            return mContext;
        }
    };

    public HeadsUpTouchHelper.Callback getHeadsUpCallback() {
        return mHeadsUpCallback;
    }

    void onGroupExpandChanged(ExpandableNotificationRow changedRow, boolean expanded) {
        boolean animated = mAnimationsEnabled && (mIsExpanded || changedRow.isPinned());
        if (animated) {
            mExpandedGroupView = changedRow;
            mNeedsAnimation = true;
        }

        changedRow.setChildrenExpanded(expanded);
        onChildHeightChanged(changedRow, false /* needsAnimation */);

        runAfterAnimationFinished(changedRow::onFinishedExpansionChange);
    }

    private final ExpandHelper.Callback mExpandHelperCallback = new ExpandHelper.Callback() {
        @Override
        public ExpandableView getChildAtPosition(float touchX, float touchY) {
            return NotificationStackScrollLayout.this.getChildAtPosition(touchX, touchY);
        }

        @Override
        public ExpandableView getChildAtRawPosition(float touchX, float touchY) {
            return NotificationStackScrollLayout.this.getChildAtRawPosition(touchX, touchY);
        }

        @Override
        public boolean canChildBeExpanded(View v) {
            return v instanceof ExpandableNotificationRow
                    && ((ExpandableNotificationRow) v).isExpandable()
                    && !((ExpandableNotificationRow) v).areGutsExposed()
                    && (mIsExpanded || !((ExpandableNotificationRow) v).isPinned());
        }

        /* Only ever called as a consequence of an expansion gesture in the shade. */
        @Override
        public void setUserExpandedChild(View v, boolean userExpanded) {
            if (v instanceof ExpandableNotificationRow row) {
                if (userExpanded && onKeyguard()) {
                    // Due to a race when locking the screen while touching, a notification may be
                    // expanded even after we went back to keyguard. An example of this happens if
                    // you click in the empty space while expanding a group.

                    // We also need to un-user lock it here, since otherwise the content height
                    // calculated might be wrong. We also can't invert the two calls since
                    // un-userlocking it will trigger a layout switch in the content view.
                    row.setUserLocked(false);
                    updateContentHeight();
                    notifyHeightChangeListener(row);
                    return;
                }
                row.setUserExpanded(userExpanded, true /* allowChildrenExpansion */);
                row.onExpandedByGesture(userExpanded);
            }
        }

        @Override
        public void setExpansionCancelled(View v) {
            if (v instanceof ExpandableNotificationRow) {
                ((ExpandableNotificationRow) v).setGroupExpansionChanging(false);
            }
        }

        @Override
        public void setUserLockedChild(View v, boolean userLocked) {
            if (v instanceof ExpandableNotificationRow) {
                ((ExpandableNotificationRow) v).setUserLocked(userLocked);
            }
            cancelLongPress();
            requestDisallowInterceptTouchEvent(true);
        }

        @Override
        public void expansionStateChanged(boolean isExpanding) {
            mExpandingNotification = isExpanding;
            if (!mExpandedInThisMotion) {
                mMaxScrollAfterExpand = getOwnScrollY();
                mExpandedInThisMotion = true;
            }
        }

        @Override
        public int getMaxExpandHeight(ExpandableView view) {
            return view.getMaxContentHeight();
        }
    };

    public ExpandHelper.Callback getExpandHelperCallback() {
        return mExpandHelperCallback;
    }

    float getAppearFraction() {
        return mLastSentAppear;
    }

    float getExpandedHeight() {
        return mLastSentExpandedHeight;
    }

    /**
     * Enum for selecting some or all notification rows (does not included non-notif views).
     */
    @Retention(SOURCE)
    @IntDef({ROWS_ALL, ROWS_HIGH_PRIORITY, ROWS_GENTLE})
    @interface SelectedRows {
    }

    /**
     * All rows representing notifs.
     */
    public static final int ROWS_ALL = 0;
    /**
     * Only rows where entry.isHighPriority() is true.
     */
    public static final int ROWS_HIGH_PRIORITY = 1;
    /**
     * Only rows where entry.isHighPriority() is false.
     */
    public static final int ROWS_GENTLE = 2;

    interface ClearAllListener {
        void onClearAll(@SelectedRows int selectedRows);
    }

    interface ClearAllAnimationListener {
        void onAnimationEnd(
                List<ExpandableNotificationRow> viewsToRemove, @SelectedRows int selectedRows);
    }

    // -------------------- Getters / Setters for the SceneContainer refactor ----------------------

    /** Use {@link ScrollViewFields#intrinsicStackHeight}, when SceneContainerFlag is enabled. */
    private int getContentHeight() {
        SceneContainerFlag.assertInLegacyMode();
        return mContentHeight;
    }

    private void setContentHeight(int contentHeight) {
        SceneContainerFlag.assertInLegacyMode();
        mContentHeight = contentHeight;
    }

    /**
     * Use {@link ScrollViewFields#intrinsicStackHeight}}, when SceneContainerFlag is enabled.
     * @return the height of the content ignoring the footer.
     */
    public float getIntrinsicContentHeight() {
        SceneContainerFlag.assertInLegacyMode();
        return mIntrinsicContentHeight;
    }

    private void setIntrinsicContentHeight(float intrinsicContentHeight) {
        SceneContainerFlag.assertInLegacyMode();
        mIntrinsicContentHeight = intrinsicContentHeight;
    }

    private int getMaxTopPadding() {
        SceneContainerFlag.assertInLegacyMode();
        return mMaxTopPadding;
    }

    /** Not used with SceneContainerFlag, because we rely on the placeholder for placement. */
    public void setMaxTopPadding(int maxTopPadding) {
        SceneContainerFlag.assertInLegacyMode();
        mMaxTopPadding = maxTopPadding;
    }

    private void spewLog(String logMsg) {
        if (SPEW) {
            Log.v(TAG, logMsg);
        }
    }
}
