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

Commit d76dd602 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Introducing the MagneticNotificationRowManager.

The manager handles the translation of ExpandableNotificationRows so
that they implement a magnetic attachment to each other. It also handles
the logic of triggering snapping and detaching animations as well as
haptics.

This is the first of a series of two changes. The second change
integrates the manager with the relevant components that handle
notification swipes.

Flag: NONE The usage of the manager and its capabilities are flagged by
  the change that integrates with the NSSL controller
Test: MagneticNotificationRowManagerImplTest
Test: NotificationTargetsHelperTest
Bug: 390179908
Change-Id: Idd915240e1e7543233b0f3aa9f53607feb7e224b
parent 5b031543
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