Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6893f8be authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez Committed by Android (Google) Code Review
Browse files

Merge "Introducing the MagneticNotificationRowManager." into main

parents f087b8d1 d76dd602
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -985,6 +985,11 @@ class PhysicsAnimator<T> private constructor (target: T) {
            return animators[target] as PhysicsAnimator<T>
        }

        @JvmStatic
        @Suppress("UNCHECKED_CAST")
        fun <T: Any> getInstanceIfExists(target: T): PhysicsAnimator<T>? =
            animators[target] as PhysicsAnimator<T>?

        /**
         * Set whether all physics animators should log a lot of information about animations.
         * Useful for debugging!
+214 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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 android.os.testableLooper
import android.testing.TestableLooper.RunWithLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.flags.FakeFeatureFlagsClassic
import com.android.systemui.haptics.msdl.fakeMSDLPlayer
import com.android.systemui.kosmos.testScope
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
import com.android.systemui.statusbar.notification.row.NotificationTestHelper
import com.android.systemui.statusbar.notification.stack.MagneticNotificationRowManagerImpl.State
import com.android.systemui.testKosmos
import com.google.android.msdl.data.model.MSDLToken
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock

@SmallTest
@RunWith(AndroidJUnit4::class)
@RunWithLooper
class MagneticNotificationRowManagerImplTest : SysuiTestCase() {

    private val featureFlags = FakeFeatureFlagsClassic()
    private val kosmos = testKosmos()
    private val childrenNumber = 5
    private val stackScrollLayout = mock<NotificationStackScrollLayout>()
    private val sectionsManager = mock<NotificationSectionsManager>()
    private val swipedMultiplier = 0.5f
    private val msdlPlayer = kosmos.fakeMSDLPlayer

    private val underTest = kosmos.magneticNotificationRowManagerImpl

    private lateinit var notificationTestHelper: NotificationTestHelper
    private lateinit var children: NotificationChildrenContainer

    @Before
    fun setUp() {
        allowTestableLooperAsMainThread()
        notificationTestHelper =
            NotificationTestHelper(mContext, mDependency, kosmos.testableLooper, featureFlags)
        children = notificationTestHelper.createGroup(childrenNumber).childrenContainer
    }

    @Test
    fun setMagneticAndRoundableTargets_onIdle_targetsGetSet() =
        kosmos.testScope.runTest {
            // WHEN the targets are set for a row
            val row = children.attachedChildren[childrenNumber / 2]
            setTargetsForRow(row)

            // THEN the magnetic and roundable targets are defined and the state is TARGETS_SET
            assertThat(underTest.currentState).isEqualTo(State.TARGETS_SET)
            assertThat(underTest.currentMagneticListeners.isNotEmpty()).isTrue()
            assertThat(underTest.currentRoundableTargets).isNotNull()
        }

    @Test
    fun setMagneticRowTranslation_whenTargetsAreSet_startsPulling() =
        kosmos.testScope.runTest {
            // GIVEN targets are set
            val row = children.attachedChildren[childrenNumber / 2]
            setTargetsForRow(row)

            // WHEN setting a translation for the swiped row
            underTest.setMagneticRowTranslation(row, translation = 100f)

            // THEN the state moves to PULLING
            assertThat(underTest.currentState).isEqualTo(State.PULLING)
        }

    @Test
    fun setMagneticRowTranslation_whenRowIsNotSwiped_doesNotSetMagneticTranslation() =
        kosmos.testScope.runTest {
            // GIVEN that targets are set
            val row = children.attachedChildren[childrenNumber / 2]
            setTargetsForRow(row)

            // WHEN setting a translation for a row that is not being swiped
            val differentRow = children.attachedChildren[childrenNumber / 2 - 1]
            val canSetMagneticTranslation =
                underTest.setMagneticRowTranslation(differentRow, translation = 100f)

            // THEN no magnetic translations are set
            assertThat(canSetMagneticTranslation).isFalse()
        }

    @Test
    fun setMagneticRowTranslation_belowThreshold_whilePulling_setsMagneticTranslations() =
        kosmos.testScope.runTest {
            // GIVEN a threshold of 100 px
            val threshold = 100f
            underTest.setSwipeThresholdPx(threshold)

            // GIVEN that targets are set and the rows are being pulled
            val row = children.attachedChildren[childrenNumber / 2]
            setTargetsForRow(row)
            underTest.setMagneticRowTranslation(row, translation = 100f)

            // WHEN setting a translation that will fall below the threshold
            val translation = threshold / swipedMultiplier - 50f
            underTest.setMagneticRowTranslation(row, translation)

            // THEN the targets continue to be pulled and translations are set
            assertThat(underTest.currentState).isEqualTo(State.PULLING)
            assertThat(row.translation).isEqualTo(swipedMultiplier * translation)
        }

    @Test
    fun setMagneticRowTranslation_aboveThreshold_whilePulling_detachesMagneticTargets() =
        kosmos.testScope.runTest {
            // GIVEN a threshold of 100 px
            val threshold = 100f
            underTest.setSwipeThresholdPx(threshold)

            // GIVEN that targets are set and the rows are being pulled
            val row = children.attachedChildren[childrenNumber / 2]
            setTargetsForRow(row)
            underTest.setMagneticRowTranslation(row, translation = 100f)

            // WHEN setting a translation that will fall above the threshold
            val translation = threshold / swipedMultiplier + 50f
            underTest.setMagneticRowTranslation(row, translation)

            // THEN the swiped view detaches and the correct detach haptics play
            assertThat(underTest.currentState).isEqualTo(State.DETACHED)
            assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.SWIPE_THRESHOLD_INDICATOR)
        }

    @Test
    fun setMagneticRowTranslation_whileDetached_setsTranslationAndStaysDetached() =
        kosmos.testScope.runTest {
            // GIVEN that the swiped view has been detached
            val row = children.attachedChildren[childrenNumber / 2]
            setDetachedState(row)

            // WHEN setting a new translation
            val translation = 300f
            underTest.setMagneticRowTranslation(row, translation)

            // THEN the swiped view continues to be detached
            assertThat(underTest.currentState).isEqualTo(State.DETACHED)
        }

    @Test
    fun onMagneticInteractionEnd_whilePulling_goesToIdle() =
        kosmos.testScope.runTest {
            // GIVEN targets are set
            val row = children.attachedChildren[childrenNumber / 2]
            setTargetsForRow(row)

            // WHEN setting a translation for the swiped row
            underTest.setMagneticRowTranslation(row, translation = 100f)

            // WHEN the interaction ends on the row
            underTest.onMagneticInteractionEnd(row, velocity = null)

            // THEN the state resets
            assertThat(underTest.currentState).isEqualTo(State.IDLE)
        }

    @Test
    fun onMagneticInteractionEnd_whileDetached_goesToIdle() =
        kosmos.testScope.runTest {
            // GIVEN the swiped row is detached
            val row = children.attachedChildren[childrenNumber / 2]
            setDetachedState(row)

            // WHEN the interaction ends on the row
            underTest.onMagneticInteractionEnd(row, velocity = null)

            // THEN the state resets
            assertThat(underTest.currentState).isEqualTo(State.IDLE)
        }

    private fun setDetachedState(row: ExpandableNotificationRow) {
        val threshold = 100f
        underTest.setSwipeThresholdPx(threshold)

        // Set the pulling state
        setTargetsForRow(row)
        underTest.setMagneticRowTranslation(row, translation = 100f)

        // Set a translation that will fall above the threshold
        val translation = threshold / swipedMultiplier + 50f
        underTest.setMagneticRowTranslation(row, translation)

        assertThat(underTest.currentState).isEqualTo(State.DETACHED)
    }

    private fun setTargetsForRow(row: ExpandableNotificationRow) {
        underTest.setMagneticAndRoundableTargets(row, stackScrollLayout, sectionsManager)
    }
}
+70 −1
Original line number Diff line number Diff line
@@ -6,8 +6,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.flags.FakeFeatureFlagsClassic
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
import com.android.systemui.statusbar.notification.row.NotificationTestHelper
import com.android.systemui.util.mockito.mock
import com.google.common.truth.Truth.assertThat
import junit.framework.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@@ -30,7 +32,7 @@ class NotificationTargetsHelperTest : SysuiTestCase() {
            NotificationTestHelper(mContext, mDependency, TestableLooper.get(this), featureFlags)
    }

    private fun notificationTargetsHelper() = NotificationTargetsHelper(featureFlags)
    private fun notificationTargetsHelper() = NotificationTargetsHelper()

    @Test
    fun targetsForFirstNotificationInGroup() {
@@ -93,4 +95,71 @@ class NotificationTargetsHelperTest : SysuiTestCase() {
            RoundableTargets(before = children.attachedChildren[1], swiped = swiped, after = null)
        assertEquals(expected, actual)
    }

    @Test
    fun findMagneticTargets_forMiddleChild_createsAllTargets() {
        val childrenNumber = 5
        val children = notificationTestHelper.createGroup(childrenNumber).childrenContainer

        // WHEN the swiped view is the one at the middle of the container
        val swiped = children.attachedChildren[childrenNumber / 2]

        // THEN all the views that surround it become targets with the swiped view at the middle
        val actual =
            notificationTargetsHelper()
                .findMagneticTargets(viewSwiped = swiped, stackScrollLayout = stackScrollLayout, 5)
        assertMagneticTargetsForChildren(actual, children.attachedChildren)
    }

    @Test
    fun findMagneticTargets_forTopChild_createsEligibleTargets() {
        val childrenNumber = 5
        val children = notificationTestHelper.createGroup(childrenNumber).childrenContainer

        // WHEN the swiped view is the first one in the container
        val swiped = children.attachedChildren[0]

        // THEN the neighboring views become targets, with the swiped view at the middle and nulls
        // to the left
        val actual =
            notificationTargetsHelper()
                .findMagneticTargets(viewSwiped = swiped, stackScrollLayout = stackScrollLayout, 5)
        val expectedRows =
            listOf(null, null, swiped, children.attachedChildren[1], children.attachedChildren[2])
        assertMagneticTargetsForChildren(actual, expectedRows)
    }

    @Test
    fun findMagneticTargets_forBottomChild_createsEligibleTargets() {
        val childrenNumber = 5
        val children = notificationTestHelper.createGroup(childrenNumber).childrenContainer

        // WHEN the view swiped is the last one in the container
        val swiped = children.attachedChildren[childrenNumber - 1]

        // THEN the neighboring views become targets, with the swiped view at the middle and nulls
        // to the right
        val actual =
            notificationTargetsHelper()
                .findMagneticTargets(viewSwiped = swiped, stackScrollLayout = stackScrollLayout, 5)
        val expectedRows =
            listOf(
                children.attachedChildren[childrenNumber - 3],
                children.attachedChildren[childrenNumber - 2],
                swiped,
                null,
                null,
            )
        assertMagneticTargetsForChildren(actual, expectedRows)
    }

    private fun assertMagneticTargetsForChildren(
        targets: List<MagneticRowListener?>,
        children: List<ExpandableNotificationRow?>,
    ) {
        assertThat(targets.size).isEqualTo(children.size)
        targets.forEachIndexed { i, target ->
            assertThat(target).isEqualTo(children[i]?.magneticRowListener)
        }
    }
}
+48 −0
Original line number Diff line number Diff line
@@ -69,6 +69,9 @@ import android.widget.ImageView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.dynamicanimation.animation.FloatPropertyCompat;
import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;

import com.android.app.animation.Interpolators;
import com.android.internal.annotations.VisibleForTesting;
@@ -119,6 +122,7 @@ import com.android.systemui.statusbar.notification.shared.TransparentHeaderFix;
import com.android.systemui.statusbar.notification.stack.AmbientState;
import com.android.systemui.statusbar.notification.stack.AnimationProperties;
import com.android.systemui.statusbar.notification.stack.ExpandableViewState;
import com.android.systemui.statusbar.notification.stack.MagneticRowListener;
import com.android.systemui.statusbar.notification.stack.NotificationChildrenContainer;
import com.android.systemui.statusbar.notification.stack.NotificationChildrenContainerLogger;
import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayout;
@@ -131,12 +135,14 @@ import com.android.systemui.statusbar.policy.dagger.RemoteInputViewSubcomponent;
import com.android.systemui.util.Compile;
import com.android.systemui.util.DumpUtilsKt;
import com.android.systemui.util.ListenerSet;
import com.android.wm.shell.shared.animation.PhysicsAnimator;

import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
@@ -356,6 +362,44 @@ public class ExpandableNotificationRow extends ActivatableNotificationView
        }
    };

    private final SpringAnimation mMagneticAnimator = new SpringAnimation(
            this, FloatPropertyCompat.createFloatPropertyCompat(TRANSLATE_CONTENT));

    private final MagneticRowListener mMagneticRowListener = new MagneticRowListener() {
        @Override
        public void setMagneticTranslation(float translation) {
            if (mMagneticAnimator.isRunning()) {
                mMagneticAnimator.animateToFinalPosition(translation);
            } else {
                setTranslation(translation);
            }
        }

        @Override
        public void triggerMagneticForce(float endTranslation, @NonNull SpringForce springForce,
                float startVelocity) {
            cancelMagneticAnimations();
            mMagneticAnimator.setSpring(springForce);
            mMagneticAnimator.setStartVelocity(startVelocity);
            mMagneticAnimator.animateToFinalPosition(endTranslation);
        }

        @Override
        public void cancelMagneticAnimations() {
            cancelSnapBackAnimation();
            cancelTranslateAnimation();
            mMagneticAnimator.cancel();
        }
    };

    private void cancelSnapBackAnimation() {
        PhysicsAnimator<ExpandableNotificationRow> animator =
                PhysicsAnimator.getInstanceIfExists(this /* target */);
        if (animator != null) {
            animator.cancel();
        }
    }

    /**
     * Toggles expansion state.
     */
@@ -4285,4 +4329,8 @@ public class ExpandableNotificationRow extends ActivatableNotificationView
        }
        mLogger.logRemoveTransientRow(row.getEntry(), getEntry());
    }

    public MagneticRowListener getMagneticRowListener() {
        return mMagneticRowListener;
    }
}
+16 −0
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import com.android.systemui.log.dagger.NotificationLog
import com.android.systemui.log.dagger.NotificationRenderLog
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.logKey
import com.android.systemui.statusbar.notification.stack.MagneticNotificationRowManagerImpl
import javax.inject.Inject

class NotificationRowLogger
@@ -203,6 +204,21 @@ constructor(
            { "onAppearAnimationFinished childKey: $str1 isAppear:$bool1 cancelled:$bool2" },
        )
    }

    fun logMagneticAndRoundableTargetsNotSet(
        state: MagneticNotificationRowManagerImpl.State,
        entry: NotificationEntry,
    ) {
        buffer.log(
            TAG,
            LogLevel.ERROR,
            {
                str1 = entry.logKey
                str2 = state.name
            },
            { "Failed to set magnetic and roundable targets for $str1 on state $str2." },
        )
    }
}

private const val TAG = "NotifRow"
Loading