1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.wm.shell.common.magnetictarget
17 
18 import android.testing.AndroidTestingRunner
19 import android.testing.TestableLooper
20 import android.view.MotionEvent
21 import android.view.View
22 import androidx.dynamicanimation.animation.FloatPropertyCompat
23 import androidx.test.filters.SmallTest
24 import com.android.wm.shell.ShellTestCase
25 import com.android.wm.shell.animation.PhysicsAnimatorTestUtils
26 import org.junit.Assert.assertEquals
27 import org.junit.Assert.assertFalse
28 import org.junit.Assert.assertTrue
29 import org.junit.Before
30 import org.junit.Test
31 import org.junit.runner.RunWith
32 import org.mockito.ArgumentMatchers
33 import org.mockito.ArgumentMatchers.anyFloat
34 import org.mockito.Mockito
35 import org.mockito.Mockito.`when`
36 import org.mockito.Mockito.doAnswer
37 import org.mockito.Mockito.mock
38 import org.mockito.Mockito.never
39 import org.mockito.Mockito.times
40 import org.mockito.Mockito.verify
41 import org.mockito.Mockito.verifyNoMoreInteractions
42 
43 @TestableLooper.RunWithLooper
44 @RunWith(AndroidTestingRunner::class)
45 @SmallTest
46 class MagnetizedObjectTest : ShellTestCase() {
47     /** Incrementing value for fake MotionEvent timestamps. */
48     private var time = 0L
49 
50     /** Value to add to each new MotionEvent's timestamp. */
51     private var timeStep = 100
52 
53     private val underlyingObject = this
54 
55     private lateinit var targetView: View
56 
57     private val targetSize = 200
58     private val targetCenterX = 500
59     private val targetCenterY = 900
60     private val magneticFieldRadius = 200
61 
62     private var objectX = 0f
63     private var objectY = 0f
64     private val objectSize = 50f
65 
66     private lateinit var magneticTarget: MagnetizedObject.MagneticTarget
67     private lateinit var magnetizedObject: MagnetizedObject<*>
68     private lateinit var magnetListener: MagnetizedObject.MagnetListener
69 
70     private val xProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
71         override fun setValue(target: MagnetizedObjectTest?, value: Float) {
72             objectX = value
73         }
74         override fun getValue(target: MagnetizedObjectTest?): Float {
75             return objectX
76         }
77     }
78 
79     private val yProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
80         override fun setValue(target: MagnetizedObjectTest?, value: Float) {
81             objectY = value
82         }
83 
84         override fun getValue(target: MagnetizedObjectTest?): Float {
85             return objectY
86         }
87     }
88 
89     @Before
90     fun setup() {
91         PhysicsAnimatorTestUtils.prepareForTest()
92 
93         // Mock the view since a real view's getLocationOnScreen() won't work unless it's attached
94         // to a real window (it'll always return x = 0, y = 0).
95         targetView = mock(View::class.java)
96         `when`(targetView.context).thenReturn(context)
97 
98         // The mock target view will pretend that it's 200x200, and at (400, 800). This means it's
99         // occupying the bounds (400, 800, 600, 1000) and it has a center of (500, 900).
100         `when`(targetView.width).thenReturn(targetSize)  // width = 200
101         `when`(targetView.height).thenReturn(targetSize) // height = 200
102         doAnswer { invocation ->
103             (invocation.arguments[0] as IntArray).also { location ->
104                 // Return the top left of the target.
105                 location[0] = targetCenterX - targetSize / 2 // x = 400
106                 location[1] = targetCenterY - targetSize / 2 // y = 800
107             }
108         }.`when`(targetView).getLocationOnScreen(ArgumentMatchers.any())
109         doAnswer { invocation ->
110             (invocation.arguments[0] as Runnable).run()
111             true
112         }.`when`(targetView).post(ArgumentMatchers.any())
113         `when`(targetView.context).thenReturn(context)
114 
115         magneticTarget = MagnetizedObject.MagneticTarget(targetView, magneticFieldRadius)
116 
117         magnetListener = mock(MagnetizedObject.MagnetListener::class.java)
118         magnetizedObject = object : MagnetizedObject<MagnetizedObjectTest>(
119                 context, underlyingObject, xProperty, yProperty) {
120             override fun getWidth(underlyingObject: MagnetizedObjectTest): Float {
121                 return objectSize
122             }
123 
124             override fun getHeight(underlyingObject: MagnetizedObjectTest): Float {
125                 return objectSize
126             }
127 
128             override fun getLocationOnScreen(
129                 underlyingObject: MagnetizedObjectTest,
130                 loc: IntArray
131             ) {
132                 loc[0] = objectX.toInt()
133                 loc[1] = objectY.toInt() }
134         }
135 
136         magnetizedObject.magnetListener = magnetListener
137         magnetizedObject.addTarget(magneticTarget)
138 
139         timeStep = 100
140     }
141 
142     @Test
143     fun testMotionEventConsumption() {
144         // Start at (0, 0). No magnetic field here.
145         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
146                 x = 0, y = 0, action = MotionEvent.ACTION_DOWN)))
147 
148         // Move to (400, 400), which is solidly outside the magnetic field.
149         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
150                 x = 200, y = 200)))
151 
152         // Move to (305, 705). This would be in the magnetic field radius if magnetic fields were
153         // square. It's not, because they're not.
154         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
155                 x = targetCenterX - magneticFieldRadius + 5,
156                 y = targetCenterY - magneticFieldRadius + 5)))
157 
158         // Move to (400, 800). That's solidly in the radius so the magnetic target should begin
159         // consuming events.
160         assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
161                 x = targetCenterX - 100,
162                 y = targetCenterY - 100)))
163 
164         // Release at (400, 800). Since we're in the magnetic target, it should return true and
165         // consume the ACTION_UP.
166         assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
167                 x = 400, y = 800, action = MotionEvent.ACTION_UP)))
168 
169         // ACTION_DOWN outside the field.
170         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
171                 x = 200, y = 200, action = MotionEvent.ACTION_DOWN)))
172 
173         // Move to the center. We absolutely should consume events there.
174         assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
175                 x = targetCenterX,
176                 y = targetCenterY)))
177 
178         // Drag out to (0, 0) and we should be returning false again.
179         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
180                 x = 0, y = 0)))
181 
182         // The ACTION_UP event shouldn't be consumed either since it's outside the field.
183         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
184                 x = 0, y = 0, action = MotionEvent.ACTION_UP)))
185     }
186 
187     @Test
188     fun testMotionEventConsumption_downInMagneticField() {
189         // We should not consume DOWN events even if they occur in the field.
190         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
191                 x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_DOWN)))
192     }
193 
194     @Test
195     fun testMoveIntoAroundAndOutOfMagneticField() {
196         // Move around but don't touch the magnetic field.
197         dispatchMotionEvents(
198                 getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
199                 getMotionEvent(x = 100, y = 100),
200                 getMotionEvent(x = 200, y = 200))
201 
202         // You can't become unstuck if you were never stuck in the first place.
203         verify(magnetListener, never()).onStuckToTarget(magneticTarget)
204         verify(magnetListener, never()).onUnstuckFromTarget(
205                 eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
206                 eq(false))
207 
208         // Move into and then around inside the magnetic field.
209         dispatchMotionEvents(
210                 getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
211                 getMotionEvent(x = targetCenterX, y = targetCenterY),
212                 getMotionEvent(x = targetCenterX + 100, y = targetCenterY + 100))
213 
214         // We should only have received one call to onStuckToTarget and none to unstuck.
215         verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
216         verify(magnetListener, never()).onUnstuckFromTarget(
217                 eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
218                 eq(false))
219 
220         // Move out of the field and then release.
221         dispatchMotionEvents(
222                 getMotionEvent(x = 100, y = 100),
223                 getMotionEvent(x = 100, y = 100, action = MotionEvent.ACTION_UP))
224 
225         // We should have received one unstuck call and no more stuck calls. We also should never
226         // have received an onReleasedInTarget call.
227         verify(magnetListener, times(1)).onUnstuckFromTarget(
228                 eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
229                 eq(false))
230         verifyNoMoreInteractions(magnetListener)
231     }
232 
233     @Test
234     fun testMoveIntoOutOfAndBackIntoMagneticField() {
235         // Move into the field
236         dispatchMotionEvents(
237                 getMotionEvent(
238                         x = targetCenterX - magneticFieldRadius,
239                         y = targetCenterY - magneticFieldRadius,
240                         action = MotionEvent.ACTION_DOWN),
241                 getMotionEvent(
242                         x = targetCenterX, y = targetCenterY))
243 
244         verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
245         verify(magnetListener, never()).onReleasedInTarget(magneticTarget)
246 
247         // Move back out.
248         dispatchMotionEvents(
249                 getMotionEvent(
250                         x = targetCenterX - magneticFieldRadius,
251                         y = targetCenterY - magneticFieldRadius))
252 
253         verify(magnetListener, times(1)).onUnstuckFromTarget(
254                 eq(magneticTarget), ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
255                 eq(false))
256         verify(magnetListener, never()).onReleasedInTarget(magneticTarget)
257 
258         // Move in again and release in the magnetic field.
259         dispatchMotionEvents(
260                 getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
261                 getMotionEvent(x = targetCenterX + 50, y = targetCenterY + 50),
262                 getMotionEvent(x = targetCenterX, y = targetCenterY),
263                 getMotionEvent(
264                         x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_UP))
265 
266         verify(magnetListener, times(2)).onStuckToTarget(magneticTarget)
267         verify(magnetListener).onReleasedInTarget(magneticTarget)
268         verifyNoMoreInteractions(magnetListener)
269     }
270 
271     @Test
272     fun testFlingTowardsTarget_towardsTarget() {
273         timeStep = 10
274 
275         // Forcefully fling the object towards the target (but never touch the magnetic field).
276         dispatchMotionEvents(
277                 getMotionEvent(
278                         x = targetCenterX,
279                         y = 0,
280                         action = MotionEvent.ACTION_DOWN),
281                 getMotionEvent(
282                         x = targetCenterX,
283                         y = targetCenterY / 2),
284                 getMotionEvent(
285                         x = targetCenterX,
286                         y = targetCenterY - magneticFieldRadius * 2,
287                         action = MotionEvent.ACTION_UP))
288 
289         // Nevertheless it should have ended up stuck to the target.
290         verify(magnetListener, times(1)).onStuckToTarget(magneticTarget)
291     }
292 
293     @Test
294     fun testFlingTowardsTarget_towardsButTooSlow() {
295         // Very, very slowly fling the object towards the target (but never touch the magnetic
296         // field). This value is only used to create MotionEvent timestamps, it will not block the
297         // test for 10 seconds.
298         timeStep = 10000
299         dispatchMotionEvents(
300                 getMotionEvent(
301                         x = targetCenterX,
302                         y = 0,
303                         action = MotionEvent.ACTION_DOWN),
304                 getMotionEvent(
305                         x = targetCenterX,
306                         y = targetCenterY / 2),
307                 getMotionEvent(
308                         x = targetCenterX,
309                         y = targetCenterY - magneticFieldRadius * 2,
310                         action = MotionEvent.ACTION_UP))
311 
312         // No sticking should have occurred.
313         verifyNoMoreInteractions(magnetListener)
314     }
315 
316     @Test
317     fun testFlingTowardsTarget_missTarget() {
318         timeStep = 10
319         // Forcefully fling the object down, but not towards the target.
320         dispatchMotionEvents(
321                 getMotionEvent(
322                         x = 0,
323                         y = 0,
324                         action = MotionEvent.ACTION_DOWN),
325                 getMotionEvent(
326                         x = 0,
327                         y = targetCenterY / 2),
328                 getMotionEvent(
329                         x = 0,
330                         y = targetCenterY - magneticFieldRadius * 2,
331                         action = MotionEvent.ACTION_UP))
332 
333         verifyNoMoreInteractions(magnetListener)
334     }
335 
336     @Test
337     fun testMagnetAnimation() {
338         // Make sure the object starts at (0, 0).
339         assertEquals(0f, objectX)
340         assertEquals(0f, objectY)
341 
342         // Trigger the magnet animation, and block the test until it ends.
343         PhysicsAnimatorTestUtils.setAllAnimationsBlock(true)
344         magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
345                 x = targetCenterX - 250,
346                 y = targetCenterY - 250,
347                 action = MotionEvent.ACTION_DOWN))
348 
349         magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
350                 x = targetCenterX,
351                 y = targetCenterY))
352 
353         // The object's (top-left) position should now position it centered over the target.
354         assertEquals(targetCenterX - objectSize / 2, objectX)
355         assertEquals(targetCenterY - objectSize / 2, objectY)
356     }
357 
358     @Test
359     fun testMultipleTargets() {
360         val secondMagneticTarget = getSecondMagneticTarget()
361 
362         // Drag into the second target.
363         dispatchMotionEvents(
364                 getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
365                 getMotionEvent(x = 100, y = 900))
366 
367         // Verify that we received an onStuck for the second target, and no others.
368         verify(magnetListener).onStuckToTarget(secondMagneticTarget)
369         verifyNoMoreInteractions(magnetListener)
370 
371         // Drag into the original target.
372         dispatchMotionEvents(
373                 getMotionEvent(x = 0, y = 0),
374                 getMotionEvent(x = 500, y = 900))
375 
376         // We should have unstuck from the second one and stuck into the original one.
377         verify(magnetListener).onUnstuckFromTarget(
378                 eq(secondMagneticTarget), anyFloat(), anyFloat(), eq(false))
379         verify(magnetListener).onStuckToTarget(magneticTarget)
380         verifyNoMoreInteractions(magnetListener)
381     }
382 
383     @Test
384     fun testMultipleTargets_flingIntoSecond() {
385         val secondMagneticTarget = getSecondMagneticTarget()
386 
387         timeStep = 10
388 
389         // Fling towards the second target.
390         dispatchMotionEvents(
391                 getMotionEvent(x = 100, y = 0, action = MotionEvent.ACTION_DOWN),
392                 getMotionEvent(x = 100, y = 350),
393                 getMotionEvent(x = 100, y = 650, action = MotionEvent.ACTION_UP))
394 
395         // Verify that we received an onStuck for the second target.
396         verify(magnetListener).onStuckToTarget(secondMagneticTarget)
397 
398         // Fling towards the first target.
399         dispatchMotionEvents(
400                 getMotionEvent(x = 300, y = 0, action = MotionEvent.ACTION_DOWN),
401                 getMotionEvent(x = 400, y = 350),
402                 getMotionEvent(x = 500, y = 650, action = MotionEvent.ACTION_UP))
403 
404         // Verify that we received onStuck for the original target.
405         verify(magnetListener).onStuckToTarget(magneticTarget)
406     }
407 
408     private fun getSecondMagneticTarget(): MagnetizedObject.MagneticTarget {
409         // The first target view is at bounds (400, 800, 600, 1000) and it has a center of
410         // (500, 900). We'll add a second one at bounds (0, 800, 200, 1000) with center (100, 900).
411         val secondTargetView = mock(View::class.java)
412         var secondTargetCenterX = 100
413         var secondTargetCenterY = 900
414 
415         `when`(secondTargetView.context).thenReturn(context)
416         `when`(secondTargetView.width).thenReturn(targetSize)  // width = 200
417         `when`(secondTargetView.height).thenReturn(targetSize) // height = 200
418         doAnswer { invocation ->
419             (invocation.arguments[0] as Runnable).run()
420             true
421         }.`when`(secondTargetView).post(ArgumentMatchers.any())
422         doAnswer { invocation ->
423             (invocation.arguments[0] as IntArray).also { location ->
424                 // Return the top left of the target.
425                 location[0] = secondTargetCenterX - targetSize / 2 // x = 0
426                 location[1] = secondTargetCenterY - targetSize / 2 // y = 800
427             }
428         }.`when`(secondTargetView).getLocationOnScreen(ArgumentMatchers.any())
429 
430         return magnetizedObject.addTarget(secondTargetView, magneticFieldRadius)
431     }
432 
433     /**
434      * Return a MotionEvent at the given coordinates, with the given action (or MOVE by default).
435      * The event's time fields will be incremented by 10ms each time this is called, so tha
436      * VelocityTracker works.
437      */
438     private fun getMotionEvent(
439         x: Int,
440         y: Int,
441         action: Int = MotionEvent.ACTION_MOVE
442     ): MotionEvent {
443         return MotionEvent.obtain(time, time, action, x.toFloat(), y.toFloat(), 0)
444                 .also { time += timeStep }
445     }
446 
447     /** Dispatch all of the provided events to the target view. */
448     private fun dispatchMotionEvents(vararg events: MotionEvent) {
449         events.forEach { magnetizedObject.maybeConsumeMotionEvent(it) }
450     }
451 
452     /** Prevents Kotlin from being mad that eq() is nullable. */
453     private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
454 }