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

Commit e663f87c authored by Josh Tsuji's avatar Josh Tsuji Committed by Android (Google) Code Review
Browse files

Merge changes Icf5b3bc0,I97ae15d8

* changes:
  Magnetic target library!
  Modifies PhysicsAnimator's cancellation methods to support tests.
parents 2e8b3e21 cb9312f8
Loading
Loading
Loading
Loading
+31 −6
Original line number Diff line number Diff line
@@ -125,6 +125,13 @@ class PhysicsAnimator<T> private constructor (val target: T) {
     */
    internal var startAction: () -> Unit = ::startInternal

    /**
     * Action to run when [cancel] is called. This can be changed by
     * [PhysicsAnimatorTestUtils.prepareForTest] to cancel animations from the main thread, which
     * is required.
     */
    internal var cancelAction: (Set<FloatPropertyCompat<in T>>) -> Unit = ::cancelInternal

    /**
     * Springs a property to the given value, using the provided configuration settings.
     *
@@ -429,10 +436,13 @@ class PhysicsAnimator<T> private constructor (val target: T) {
                        max = max(currentValue, this.max)
                    }

                    // Apply the configuration and start the animation. Since flings can't be
                    // redirected while in motion, cancel it first.
                    // Flings can't be updated to a new position while maintaining velocity, because
                    // we're using the explicitly provided start velocity. Cancel any flings (or
                    // springs) on this property before flinging.
                    cancel(animatedProperty)

                    // Apply the configuration and start the animation.
                    getFlingAnimation(animatedProperty)
                            .also { it.cancel() }
                            .also { flingConfig.applyToAnimation(it) }
                            .start()
                }
@@ -707,11 +717,26 @@ class PhysicsAnimator<T> private constructor (val target: T) {
        return springConfigs.keys.union(flingConfigs.keys)
    }

    /**
     * Cancels the given properties. This is typically called immediately by [cancel], unless this
     * animator is under test.
     */
    internal fun cancelInternal(properties: Set<FloatPropertyCompat<in T>>) {
        for (property in properties) {
            flingAnimations[property]?.cancel()
            springAnimations[property]?.cancel()
        }
    }

    /** Cancels all in progress animations on all properties. */
    fun cancel() {
        for (dynamicAnim in flingAnimations.values.union(springAnimations.values)) {
            dynamicAnim.cancel()
        cancelAction(flingAnimations.keys)
        cancelAction(springAnimations.keys)
    }

    /** Cancels in progress animations on the provided properties only. */
    fun cancel(vararg properties: FloatPropertyCompat<in T>) {
        cancelAction(properties.toSet())
    }

    /**
+26 −0
Original line number Diff line number Diff line
@@ -363,8 +363,12 @@ object PhysicsAnimatorTestUtils {
        private val testEndListeners = ArrayList<PhysicsAnimator.EndListener<T>>()
        private val testUpdateListeners = ArrayList<PhysicsAnimator.UpdateListener<T>>()

        /** Whether we're currently in the middle of executing startInternal(). */
        private var currentlyRunningStartInternal = false

        init {
            animator.startAction = ::startForTest
            animator.cancelAction = ::cancelForTest
        }

        internal fun addTestEndListener(listener: PhysicsAnimator.EndListener<T>) {
@@ -437,7 +441,29 @@ object PhysicsAnimatorTestUtils {
                    }
                })

                currentlyRunningStartInternal = true
                animator.startInternal()
                currentlyRunningStartInternal = false
                unblockLatch.countDown()
            }

            unblockLatch.await(timeoutMs, TimeUnit.MILLISECONDS)
        }

        private fun cancelForTest(properties: Set<FloatPropertyCompat<in T>>) {
            // If this was called from startInternal, we are already on the animation thread, and
            // should just call cancelInternal rather than posting it. If we post it, the
            // cancellation will occur after the rest of startInternal() and we'll immediately
            // cancel the animation we worked so hard to start!
            if (currentlyRunningStartInternal) {
                animator.cancelInternal(properties)
                return
            }

            val unblockLatch = CountDownLatch(1)

            animationThreadHandler.post {
                animator.cancelInternal(properties)
                unblockLatch.countDown()
            }

+618 −0

File added.

Preview size limit exceeded, changes collapsed.

+442 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 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.util.magnetictarget

import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.MotionEvent
import android.view.View
import androidx.dynamicanimation.animation.FloatPropertyCompat
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.util.animation.PhysicsAnimatorTestUtils
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers
import org.mockito.ArgumentMatchers.anyFloat
import org.mockito.Mockito
import org.mockito.Mockito.`when`
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoMoreInteractions

@TestableLooper.RunWithLooper
@RunWith(AndroidTestingRunner::class)
@SmallTest
class MagnetizedObjectTest : SysuiTestCase() {
    /** Incrementing value for fake MotionEvent timestamps. */
    private var time = 0L

    /** Value to add to each new MotionEvent's timestamp. */
    private var timeStep = 100

    private val underlyingObject = this

    private lateinit var targetView: View

    private val targetSize = 200
    private val targetCenterX = 500
    private val targetCenterY = 900
    private val magneticFieldRadius = 200

    private var objectX = 0f
    private var objectY = 0f
    private val objectSize = 50f

    private lateinit var magneticTarget: MagnetizedObject.MagneticTarget
    private lateinit var magnetizedObject: MagnetizedObject<*>
    private lateinit var magnetListener: MagnetizedObject.MagnetListener

    private val xProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
        override fun setValue(target: MagnetizedObjectTest?, value: Float) {
            objectX = value
        }
        override fun getValue(target: MagnetizedObjectTest?): Float {
            return objectX
        }
    }

    private val yProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
        override fun setValue(target: MagnetizedObjectTest?, value: Float) {
            objectY = value
        }

        override fun getValue(target: MagnetizedObjectTest?): Float {
            return objectY
        }
    }

    @Before
    fun setup() {
        PhysicsAnimatorTestUtils.prepareForTest()

        // Mock the view since a real view's getLocationOnScreen() won't work unless it's attached
        // to a real window (it'll always return x = 0, y = 0).
        targetView = mock(View::class.java)
        `when`(targetView.context).thenReturn(context)

        // The mock target view will pretend that it's 200x200, and at (400, 800). This means it's
        // occupying the bounds (400, 800, 600, 1000) and it has a center of (500, 900).
        `when`(targetView.width).thenReturn(targetSize)  // width = 200
        `when`(targetView.height).thenReturn(targetSize) // height = 200
        doAnswer { invocation ->
            (invocation.arguments[0] as IntArray).also { location ->
                // Return the top left of the target.
                location[0] = targetCenterX - targetSize / 2 // x = 400
                location[1] = targetCenterY - targetSize / 2 // y = 800
            }
        }.`when`(targetView).getLocationOnScreen(ArgumentMatchers.any())
        `when`(targetView.context).thenReturn(context)

        magneticTarget = MagnetizedObject.MagneticTarget(targetView, magneticFieldRadius)

        magnetListener = mock(MagnetizedObject.MagnetListener::class.java)
        magnetizedObject = object : MagnetizedObject<MagnetizedObjectTest>(
                context, underlyingObject, xProperty, yProperty) {
            override fun getWidth(underlyingObject: MagnetizedObjectTest): Float {
                return objectSize
            }

            override fun getHeight(underlyingObject: MagnetizedObjectTest): Float {
                return objectSize
            }

            override fun getLocationOnScreen(
                underlyingObject: MagnetizedObjectTest,
                loc: IntArray
            ) {
                loc[0] = objectX.toInt()
                loc[1] = objectY.toInt() }
        }

        magnetizedObject.magnetListener = magnetListener
        magnetizedObject.addTarget(magneticTarget)

        timeStep = 100
    }

    @Test
    fun testMotionEventConsumption() {
        // Start at (0, 0). No magnetic field here.
        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = 0, y = 0, action = MotionEvent.ACTION_DOWN)))

        // Move to (400, 400), which is solidly outside the magnetic field.
        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = 200, y = 200)))

        // Move to (305, 705). This would be in the magnetic field radius if magnetic fields were
        // square. It's not, because they're not.
        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = targetCenterX - magneticFieldRadius + 5,
                y = targetCenterY - magneticFieldRadius + 5)))

        // Move to (400, 800). That's solidly in the radius so the magnetic target should begin
        // consuming events.
        assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = targetCenterX - 100,
                y = targetCenterY - 100)))

        // Release at (400, 800). Since we're in the magnetic target, it should return true and
        // consume the ACTION_UP.
        assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = 400, y = 800, action = MotionEvent.ACTION_UP)))

        // ACTION_DOWN outside the field.
        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = 200, y = 200, action = MotionEvent.ACTION_DOWN)))

        // Move to the center. We absolutely should consume events there.
        assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = targetCenterX,
                y = targetCenterY)))

        // Drag out to (0, 0) and we should be returning false again.
        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = 0, y = 0)))

        // The ACTION_UP event shouldn't be consumed either since it's outside the field.
        assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = 0, y = 0, action = MotionEvent.ACTION_UP)))
    }

    @Test
    fun testMotionEventConsumption_downInMagneticField() {
        // We should consume DOWN events if they occur in the field.
        assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_DOWN)))
    }

    @Test
    fun testMoveIntoAroundAndOutOfMagneticField() {
        // Move around but don't touch the magnetic field.
        dispatchMotionEvents(
                getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
                getMotionEvent(x = 100, y = 100),
                getMotionEvent(x = 200, y = 200))

        // You can't become unstuck if you were never stuck in the first place.
        verify(magnetListener, never()).onStuckToTarget(magneticTarget)
        verify(magnetListener, never()).onUnstuckFromTarget(
                eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
                eq(false))

        // Move into and then around inside the magnetic field.
        dispatchMotionEvents(
                getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
                getMotionEvent(x = targetCenterX, y = targetCenterY),
                getMotionEvent(x = targetCenterX + 100, y = targetCenterY + 100))

        // We should only have received one call to onStuckToTarget and none to unstuck.
        verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
        verify(magnetListener, never()).onUnstuckFromTarget(
                eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
                eq(false))

        // Move out of the field and then release.
        dispatchMotionEvents(
                getMotionEvent(x = 100, y = 100),
                getMotionEvent(x = 100, y = 100, action = MotionEvent.ACTION_UP))

        // We should have received one unstuck call and no more stuck calls. We also should never
        // have received an onReleasedInTarget call.
        verify(magnetListener, times(1)).onUnstuckFromTarget(
                eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
                eq(false))
        verifyNoMoreInteractions(magnetListener)
    }

    @Test
    fun testMoveIntoOutOfAndBackIntoMagneticField() {
        // Move into the field
        dispatchMotionEvents(
                getMotionEvent(
                        x = targetCenterX - magneticFieldRadius,
                        y = targetCenterY - magneticFieldRadius,
                        action = MotionEvent.ACTION_DOWN),
                getMotionEvent(
                        x = targetCenterX, y = targetCenterY))

        verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
        verify(magnetListener, never()).onReleasedInTarget(magneticTarget)

        // Move back out.
        dispatchMotionEvents(
                getMotionEvent(
                        x = targetCenterX - magneticFieldRadius,
                        y = targetCenterY - magneticFieldRadius))

        verify(magnetListener, times(1)).onUnstuckFromTarget(
                eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
                eq(false))
        verify(magnetListener, never()).onReleasedInTarget(magneticTarget)

        // Move in again and release in the magnetic field.
        dispatchMotionEvents(
                getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
                getMotionEvent(x = targetCenterX + 50, y = targetCenterY + 50),
                getMotionEvent(x = targetCenterX, y = targetCenterY),
                getMotionEvent(
                        x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_UP))

        verify(magnetListener, times(2)).onStuckToTarget(magneticTarget)
        verify(magnetListener).onReleasedInTarget(magneticTarget)
        verifyNoMoreInteractions(magnetListener)
    }

    @Test
    fun testFlingTowardsTarget_towardsTarget() {
        timeStep = 10

        // Forcefully fling the object towards the target (but never touch the magnetic field).
        dispatchMotionEvents(
                getMotionEvent(
                        x = targetCenterX,
                        y = 0,
                        action = MotionEvent.ACTION_DOWN),
                getMotionEvent(
                        x = targetCenterX,
                        y = targetCenterY / 2),
                getMotionEvent(
                        x = targetCenterX,
                        y = targetCenterY - magneticFieldRadius * 2,
                        action = MotionEvent.ACTION_UP))

        // Nevertheless it should have ended up stuck to the target.
        verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
    }

    @Test
    fun testFlingTowardsTarget_towardsButTooSlow() {
        // Very, very slowly fling the object towards the target (but never touch the magnetic
        // field). This value is only used to create MotionEvent timestamps, it will not block the
        // test for 10 seconds.
        timeStep = 10000
        dispatchMotionEvents(
                getMotionEvent(
                        x = targetCenterX,
                        y = 0,
                        action = MotionEvent.ACTION_DOWN),
                getMotionEvent(
                        x = targetCenterX,
                        y = targetCenterY / 2),
                getMotionEvent(
                        x = targetCenterX,
                        y = targetCenterY - magneticFieldRadius * 2,
                        action = MotionEvent.ACTION_UP))

        // No sticking should have occurred.
        verifyNoMoreInteractions(magnetListener)
    }

    @Test
    fun testFlingTowardsTarget_missTarget() {
        timeStep = 10
        // Forcefully fling the object down, but not towards the target.
        dispatchMotionEvents(
                getMotionEvent(
                        x = 0,
                        y = 0,
                        action = MotionEvent.ACTION_DOWN),
                getMotionEvent(
                        x = 0,
                        y = targetCenterY / 2),
                getMotionEvent(
                        x = 0,
                        y = targetCenterY - magneticFieldRadius * 2,
                        action = MotionEvent.ACTION_UP))

        verifyNoMoreInteractions(magnetListener)
    }

    @Test
    fun testMagnetAnimation() {
        // Make sure the object starts at (0, 0).
        assertEquals(0f, objectX)
        assertEquals(0f, objectY)

        // Trigger the magnet animation, and block the test until it ends.
        PhysicsAnimatorTestUtils.setAllAnimationsBlock(true)
        magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
                x = targetCenterX,
                y = targetCenterY,
                action = MotionEvent.ACTION_DOWN))

        // The object's (top-left) position should now position it centered over the target.
        assertEquals(targetCenterX - objectSize / 2, objectX)
        assertEquals(targetCenterY - objectSize / 2, objectY)
    }

    @Test
    fun testMultipleTargets() {
        val secondMagneticTarget = getSecondMagneticTarget()

        // Drag into the second target.
        dispatchMotionEvents(
                getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
                getMotionEvent(x = 100, y = 900))

        // Verify that we received an onStuck for the second target, and no others.
        verify(magnetListener).onStuckToTarget(secondMagneticTarget)
        verifyNoMoreInteractions(magnetListener)

        // Drag into the original target.
        dispatchMotionEvents(
                getMotionEvent(x = 0, y = 0),
                getMotionEvent(x = 500, y = 900))

        // We should have unstuck from the second one and stuck into the original one.
        verify(magnetListener).onUnstuckFromTarget(
                eq(secondMagneticTarget), anyFloat(), anyFloat(), eq(false))
        verify(magnetListener).onStuckToTarget(magneticTarget)
        verifyNoMoreInteractions(magnetListener)
    }

    @Test
    fun testMultipleTargets_flingIntoSecond() {
        val secondMagneticTarget = getSecondMagneticTarget()

        timeStep = 10

        // Fling towards the second target.
        dispatchMotionEvents(
                getMotionEvent(x = 100, y = 0, action = MotionEvent.ACTION_DOWN),
                getMotionEvent(x = 100, y = 350),
                getMotionEvent(x = 100, y = 650, action = MotionEvent.ACTION_UP))

        // Verify that we received an onStuck for the second target.
        verify(magnetListener).onStuckToTarget(secondMagneticTarget)

        // Fling towards the first target.
        dispatchMotionEvents(
                getMotionEvent(x = 300, y = 0, action = MotionEvent.ACTION_DOWN),
                getMotionEvent(x = 400, y = 350),
                getMotionEvent(x = 500, y = 650, action = MotionEvent.ACTION_UP))

        // Verify that we received onStuck for the original target.
        verify(magnetListener).onStuckToTarget(magneticTarget)
    }

    private fun getSecondMagneticTarget(): MagnetizedObject.MagneticTarget {
        // The first target view is at bounds (400, 800, 600, 1000) and it has a center of
        // (500, 900). We'll add a second one at bounds (0, 800, 200, 1000) with center (100, 900).
        val secondTargetView = mock(View::class.java)
        var secondTargetCenterX = 100
        var secondTargetCenterY = 900

        `when`(secondTargetView.context).thenReturn(context)
        `when`(secondTargetView.width).thenReturn(targetSize)  // width = 200
        `when`(secondTargetView.height).thenReturn(targetSize) // height = 200
        doAnswer { invocation ->
            (invocation.arguments[0] as IntArray).also { location ->
                // Return the top left of the target.
                location[0] = secondTargetCenterX - targetSize / 2 // x = 0
                location[1] = secondTargetCenterY - targetSize / 2 // y = 800
            }
        }.`when`(secondTargetView).getLocationOnScreen(ArgumentMatchers.any())

        return magnetizedObject.addTarget(secondTargetView, magneticFieldRadius)
    }

    /**
     * Return a MotionEvent at the given coordinates, with the given action (or MOVE by default).
     * The event's time fields will be incremented by 10ms each time this is called, so tha
     * VelocityTracker works.
     */
    private fun getMotionEvent(
        x: Int,
        y: Int,
        action: Int = MotionEvent.ACTION_MOVE
    ): MotionEvent {
        return MotionEvent.obtain(time, time, action, x.toFloat(), y.toFloat(), 0)
                .also { time += timeStep }
    }

    /** Dispatch all of the provided events to the target view. */
    private fun dispatchMotionEvents(vararg events: MotionEvent) {
        events.forEach { magnetizedObject.maybeConsumeMotionEvent(it) }
    }

    /** Prevents Kotlin from being mad that eq() is nullable. */
    private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
}
 No newline at end of file