1 /*
<lambda>null2  * 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.shared.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.shared.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.anyBoolean
34 import org.mockito.ArgumentMatchers.anyFloat
35 import org.mockito.Mockito
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 import org.mockito.Mockito.`when`
43 
44 @TestableLooper.RunWithLooper
45 @RunWith(AndroidTestingRunner::class)
46 @SmallTest
47 class MagnetizedObjectTest : ShellTestCase() {
48     /** Incrementing value for fake MotionEvent timestamps. */
49     private var time = 0L
50 
51     /** Value to add to each new MotionEvent's timestamp. */
52     private var timeStep = 100
53 
54     private val underlyingObject = this
55 
56     private lateinit var targetView: View
57 
58     private val targetSize = 200
59     private val targetCenterX = 500
60     private val targetCenterY = 900
61     private val magneticFieldRadius = 200
62 
63     private var objectX = 0f
64     private var objectY = 0f
65     private val objectSize = 50f
66 
67     private lateinit var magneticTarget: MagnetizedObject.MagneticTarget
68     private lateinit var magnetizedObject: MagnetizedObject<*>
69     private lateinit var magnetListener: MagnetizedObject.MagnetListener
70 
71     private val xProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
72         override fun setValue(target: MagnetizedObjectTest?, value: Float) {
73             objectX = value
74         }
75         override fun getValue(target: MagnetizedObjectTest?): Float {
76             return objectX
77         }
78     }
79 
80     private val yProperty = object : FloatPropertyCompat<MagnetizedObjectTest>("") {
81         override fun setValue(target: MagnetizedObjectTest?, value: Float) {
82             objectY = value
83         }
84 
85         override fun getValue(target: MagnetizedObjectTest?): Float {
86             return objectY
87         }
88     }
89 
90     @Before
91     fun setup() {
92         PhysicsAnimatorTestUtils.prepareForTest()
93 
94         // Mock the view since a real view's getLocationOnScreen() won't work unless it's attached
95         // to a real window (it'll always return x = 0, y = 0).
96         targetView = mock(View::class.java)
97         `when`(targetView.context).thenReturn(context)
98 
99         // The mock target view will pretend that it's 200x200, and at (400, 800). This means it's
100         // occupying the bounds (400, 800, 600, 1000) and it has a center of (500, 900).
101         `when`(targetView.width).thenReturn(targetSize) // width = 200
102         `when`(targetView.height).thenReturn(targetSize) // height = 200
103         doAnswer { invocation ->
104             (invocation.arguments[0] as IntArray).also { location ->
105                 // Return the top left of the target.
106                 location[0] = targetCenterX - targetSize / 2 // x = 400
107                 location[1] = targetCenterY - targetSize / 2 // y = 800
108             }
109         }.`when`(targetView).getLocationOnScreen(ArgumentMatchers.any())
110         doAnswer { invocation ->
111             (invocation.arguments[0] as Runnable).run()
112             true
113         }.`when`(targetView).post(ArgumentMatchers.any())
114         `when`(targetView.context).thenReturn(context)
115 
116         magneticTarget = MagnetizedObject.MagneticTarget(targetView, magneticFieldRadius)
117 
118         magnetListener = mock(MagnetizedObject.MagnetListener::class.java)
119         magnetizedObject = object : MagnetizedObject<MagnetizedObjectTest>(
120                 context, underlyingObject, xProperty, yProperty) {
121             override fun getWidth(underlyingObject: MagnetizedObjectTest): Float {
122                 return objectSize
123             }
124 
125             override fun getHeight(underlyingObject: MagnetizedObjectTest): Float {
126                 return objectSize
127             }
128 
129             override fun getLocationOnScreen(
130                 underlyingObject: MagnetizedObjectTest,
131                 loc: IntArray
132             ) {
133                 loc[0] = objectX.toInt()
134                 loc[1] = objectY.toInt() }
135         }
136 
137         magnetizedObject.magnetListener = magnetListener
138         magnetizedObject.addTarget(magneticTarget)
139 
140         timeStep = 100
141     }
142 
143     @Test
144     fun testMotionEventConsumption() {
145         // Start at (0, 0). No magnetic field here.
146         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
147                 x = 0, y = 0, action = MotionEvent.ACTION_DOWN)))
148 
149         // Move to (400, 400), which is solidly outside the magnetic field.
150         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
151                 x = 200, y = 200)))
152 
153         // Move to (305, 705). This would be in the magnetic field radius if magnetic fields were
154         // square. It's not, because they're not.
155         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
156                 x = targetCenterX - magneticFieldRadius + 5,
157                 y = targetCenterY - magneticFieldRadius + 5)))
158 
159         // Move to (400, 800). That's solidly in the radius so the magnetic target should begin
160         // consuming events.
161         assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
162                 x = targetCenterX - 100,
163                 y = targetCenterY - 100)))
164 
165         // Release at (400, 800). Since we're in the magnetic target, it should return true and
166         // consume the ACTION_UP.
167         assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
168                 x = 400, y = 800, action = MotionEvent.ACTION_UP)))
169 
170         // ACTION_DOWN outside the field.
171         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
172                 x = 200, y = 200, action = MotionEvent.ACTION_DOWN)))
173 
174         // Move to the center. We absolutely should consume events there.
175         assertTrue(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
176                 x = targetCenterX,
177                 y = targetCenterY)))
178 
179         // Drag out to (0, 0) and we should be returning false again.
180         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
181                 x = 0, y = 0)))
182 
183         // The ACTION_UP event shouldn't be consumed either since it's outside the field.
184         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
185                 x = 0, y = 0, action = MotionEvent.ACTION_UP)))
186     }
187 
188     @Test
189     fun testMotionEventConsumption_downInMagneticField() {
190         // We should not consume DOWN events even if they occur in the field.
191         assertFalse(magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
192                 x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_DOWN)))
193     }
194 
195     @Test
196     fun testMoveIntoAroundAndOutOfMagneticField() {
197         // Move around but don't touch the magnetic field.
198         dispatchMotionEvents(
199                 getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
200                 getMotionEvent(x = 100, y = 100),
201                 getMotionEvent(x = 200, y = 200))
202 
203         // You can't become unstuck if you were never stuck in the first place.
204         verify(magnetListener, never()).onStuckToTarget(magneticTarget,
205                 magnetizedObject)
206         verify(magnetListener, never()).onUnstuckFromTarget(
207                 eq(magneticTarget), eq(magnetizedObject),
208                 ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
209                 eq(false))
210 
211         // Move into and then around inside the magnetic field.
212         dispatchMotionEvents(
213                 getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
214                 getMotionEvent(x = targetCenterX, y = targetCenterY),
215                 getMotionEvent(x = targetCenterX + 100, y = targetCenterY + 100))
216 
217         // We should only have received one call to onStuckToTarget and none to unstuck.
218         verify(magnetListener, times(1)).onStuckToTarget(magneticTarget, magnetizedObject)
219         verify(magnetListener, never()).onUnstuckFromTarget(
220                 eq(magneticTarget), eq(magnetizedObject),
221                 ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
222                 eq(false))
223 
224         // Move out of the field and then release.
225         dispatchMotionEvents(
226                 getMotionEvent(x = 100, y = 100),
227                 getMotionEvent(x = 100, y = 100, action = MotionEvent.ACTION_UP))
228 
229         // We should have received one unstuck call and no more stuck calls. We also should never
230         // have received an onReleasedInTarget call.
231         verify(magnetListener, times(1)).onUnstuckFromTarget(
232                 eq(magneticTarget), eq(magnetizedObject),
233                 ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
234                 eq(false))
235         verifyNoMoreInteractions(magnetListener)
236     }
237 
238     @Test
239     fun testMoveIntoOutOfAndBackIntoMagneticField() {
240         // Move into the field
241         dispatchMotionEvents(
242                 getMotionEvent(
243                         x = targetCenterX - magneticFieldRadius,
244                         y = targetCenterY - magneticFieldRadius,
245                         action = MotionEvent.ACTION_DOWN),
246                 getMotionEvent(
247                         x = targetCenterX, y = targetCenterY))
248 
249         verify(magnetListener, times(1)).onStuckToTarget(magneticTarget, magnetizedObject)
250         verify(magnetListener, never()).onReleasedInTarget(magneticTarget, magnetizedObject)
251 
252         // Move back out.
253         dispatchMotionEvents(
254                 getMotionEvent(
255                         x = targetCenterX - magneticFieldRadius,
256                         y = targetCenterY - magneticFieldRadius))
257 
258         verify(magnetListener, times(1)).onUnstuckFromTarget(
259                 eq(magneticTarget),
260                 eq(magnetizedObject),
261                 ArgumentMatchers.anyFloat(), ArgumentMatchers.anyFloat(),
262                 eq(false))
263         verify(magnetListener, never()).onReleasedInTarget(magneticTarget, magnetizedObject)
264 
265         // Move in again and release in the magnetic field.
266         dispatchMotionEvents(
267                 getMotionEvent(x = targetCenterX - 100, y = targetCenterY - 100),
268                 getMotionEvent(x = targetCenterX + 50, y = targetCenterY + 50),
269                 getMotionEvent(x = targetCenterX, y = targetCenterY),
270                 getMotionEvent(
271                         x = targetCenterX, y = targetCenterY, action = MotionEvent.ACTION_UP))
272 
273         verify(magnetListener, times(2)).onStuckToTarget(magneticTarget, magnetizedObject)
274         verify(magnetListener).onReleasedInTarget(magneticTarget, magnetizedObject)
275         verifyNoMoreInteractions(magnetListener)
276     }
277 
278     @Test
279     fun testFlingTowardsTarget_towardsTarget() {
280         timeStep = 10
281 
282         // Forcefully fling the object towards the target (but never touch the magnetic field).
283         dispatchMotionEvents(
284                 getMotionEvent(
285                         x = 0,
286                         y = 0,
287                         action = MotionEvent.ACTION_DOWN),
288                 getMotionEvent(
289                         x = targetCenterX / 2,
290                         y = targetCenterY / 2),
291                 getMotionEvent(
292                         x = targetCenterX,
293                         y = targetCenterY - magneticFieldRadius * 2,
294                         action = MotionEvent.ACTION_UP))
295 
296         // Nevertheless it should have ended up stuck to the target.
297         verify(magnetListener, times(1)).onStuckToTarget(magneticTarget, magnetizedObject)
298     }
299 
300     @Test
301     fun testFlingTowardsTarget_towardsButTooSlow() {
302         // Very, very slowly fling the object towards the target (but never touch the magnetic
303         // field). This value is only used to create MotionEvent timestamps, it will not block the
304         // test for 10 seconds.
305         timeStep = 10000
306         dispatchMotionEvents(
307                 getMotionEvent(
308                         x = targetCenterX,
309                         y = 0,
310                         action = MotionEvent.ACTION_DOWN),
311                 getMotionEvent(
312                         x = targetCenterX,
313                         y = targetCenterY / 2),
314                 getMotionEvent(
315                         x = targetCenterX,
316                         y = targetCenterY - magneticFieldRadius * 2,
317                         action = MotionEvent.ACTION_UP))
318 
319         // No sticking should have occurred.
320         verifyNoMoreInteractions(magnetListener)
321     }
322 
323     @Test
324     fun testFlingTowardsTarget_missTarget() {
325         timeStep = 10
326         // Forcefully fling the object down, but not towards the target.
327         dispatchMotionEvents(
328                 getMotionEvent(
329                         x = 0,
330                         y = 0,
331                         action = MotionEvent.ACTION_DOWN),
332                 getMotionEvent(
333                         x = 0,
334                         y = targetCenterY / 2),
335                 getMotionEvent(
336                         x = 0,
337                         y = targetCenterY - magneticFieldRadius * 2,
338                         action = MotionEvent.ACTION_UP))
339 
340         verifyNoMoreInteractions(magnetListener)
341     }
342 
343     @Test
344     fun testMagnetAnimation() {
345         // Make sure the object starts at (0, 0).
346         assertEquals(0f, objectX)
347         assertEquals(0f, objectY)
348 
349         // Trigger the magnet animation, and block the test until it ends.
350         PhysicsAnimatorTestUtils.setAllAnimationsBlock(true)
351         magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
352                 x = targetCenterX - 250,
353                 y = targetCenterY - 250,
354                 action = MotionEvent.ACTION_DOWN))
355 
356         magnetizedObject.maybeConsumeMotionEvent(getMotionEvent(
357                 x = targetCenterX,
358                 y = targetCenterY))
359 
360         // The object's (top-left) position should now position it centered over the target.
361         assertEquals(targetCenterX - objectSize / 2, objectX)
362         assertEquals(targetCenterY - objectSize / 2, objectY)
363     }
364 
365     @Test
366     fun testMultipleTargets() {
367         val secondMagneticTarget = getSecondMagneticTarget()
368 
369         // Drag into the second target.
370         dispatchMotionEvents(
371                 getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
372                 getMotionEvent(x = 100, y = 900))
373 
374         // Verify that we received an onStuck for the second target, and no others.
375         verify(magnetListener).onStuckToTarget(secondMagneticTarget, magnetizedObject)
376         verifyNoMoreInteractions(magnetListener)
377 
378         // Drag into the original target.
379         dispatchMotionEvents(
380                 getMotionEvent(x = 0, y = 0),
381                 getMotionEvent(x = 500, y = 900))
382 
383         // We should have unstuck from the second one and stuck into the original one.
384         verify(magnetListener).onUnstuckFromTarget(
385                 eq(secondMagneticTarget), eq(magnetizedObject),
386                 anyFloat(), anyFloat(), eq(false))
387         verify(magnetListener).onStuckToTarget(magneticTarget, magnetizedObject)
388         verifyNoMoreInteractions(magnetListener)
389     }
390 
391     @Test
392     fun testMultipleTargets_flingIntoSecond() {
393         val secondMagneticTarget = getSecondMagneticTarget()
394 
395         timeStep = 10
396 
397         // Fling towards the second target.
398         dispatchMotionEvents(
399                 getMotionEvent(x = 100, y = 0, action = MotionEvent.ACTION_DOWN),
400                 getMotionEvent(x = 100, y = 350),
401                 getMotionEvent(x = 100, y = 650, action = MotionEvent.ACTION_UP))
402 
403         // Verify that we received an onStuck for the second target.
404         verify(magnetListener).onStuckToTarget(secondMagneticTarget, magnetizedObject)
405 
406         // Fling towards the first target.
407         dispatchMotionEvents(
408                 getMotionEvent(x = 300, y = 0, action = MotionEvent.ACTION_DOWN),
409                 getMotionEvent(x = 400, y = 350),
410                 getMotionEvent(x = 500, y = 650, action = MotionEvent.ACTION_UP))
411 
412         // Verify that we received onStuck for the original target.
413         verify(magnetListener).onStuckToTarget(magneticTarget, magnetizedObject)
414     }
415 
416     @Test
417     fun testMagneticTargetHasScreenOffset_moveIntoAndReleaseInTarget() {
418         magneticTarget.screenVerticalOffset = 500
419 
420         dispatchMotionEvents(getMotionEvent(x = targetCenterX, y = targetCenterY))
421         // Moved into the target location, but it should be shifted due to screen offset.
422         // Should not get stuck.
423         verify(magnetListener, never()).onStuckToTarget(magneticTarget, magnetizedObject)
424 
425         dispatchMotionEvents(getMotionEvent(x = targetCenterX, y = targetCenterY + 500))
426         verify(magnetListener).onStuckToTarget(magneticTarget, magnetizedObject)
427 
428         dispatchMotionEvents(
429             getMotionEvent(
430                 x = targetCenterX,
431                 y = targetCenterY + 500,
432                 action = MotionEvent.ACTION_UP
433             )
434         )
435 
436         verify(magnetListener).onReleasedInTarget(magneticTarget, magnetizedObject)
437         verifyNoMoreInteractions(magnetListener)
438     }
439 
440     @Test
441     fun testMagneticTargetHasScreenOffset_screenOffsetUpdates() {
442         magneticTarget.screenVerticalOffset = 500
443         val adjustedTargetCenter = targetCenterY + 500
444 
445         dispatchMotionEvents(getMotionEvent(x = targetCenterX, y = adjustedTargetCenter))
446         dispatchMotionEvents(getMotionEvent(x = 0, y = 0))
447         verify(magnetListener).onStuckToTarget(magneticTarget, magnetizedObject)
448         verify(magnetListener)
449                 .onUnstuckFromTarget(eq(magneticTarget), eq(magnetizedObject),
450                         anyFloat(), anyFloat(), anyBoolean())
451 
452         // Offset if removed, we should now get stuck at the target location
453         magneticTarget.screenVerticalOffset = 0
454         dispatchMotionEvents(getMotionEvent(x = targetCenterX, y = targetCenterY))
455         verify(magnetListener, times(2)).onStuckToTarget(magneticTarget, magnetizedObject)
456     }
457 
458     @Test
459     fun testMagneticTargetHasScreenOffset_flingTowardsTarget() {
460         timeStep = 10
461 
462         magneticTarget.screenVerticalOffset = 500
463         val adjustedTargetCenter = targetCenterY + 500
464 
465         // Forcefully fling the object towards the target (but never touch the magnetic field).
466         dispatchMotionEvents(
467             getMotionEvent(x = 0, y = 0, action = MotionEvent.ACTION_DOWN),
468             getMotionEvent(x = targetCenterX / 2, y = adjustedTargetCenter / 2),
469             getMotionEvent(
470                 x = targetCenterX,
471                 y = adjustedTargetCenter - magneticFieldRadius * 2,
472                 action = MotionEvent.ACTION_UP
473             )
474         )
475 
476         // Nevertheless it should have ended up stuck to the target.
477         verify(magnetListener, times(1)).onStuckToTarget(magneticTarget, magnetizedObject)
478     }
479 
480     private fun getSecondMagneticTarget(): MagnetizedObject.MagneticTarget {
481         // The first target view is at bounds (400, 800, 600, 1000) and it has a center of
482         // (500, 900). We'll add a second one at bounds (0, 800, 200, 1000) with center (100, 900).
483         val secondTargetView = mock(View::class.java)
484         val secondTargetCenterX = 100
485         val secondTargetCenterY = 900
486 
487         `when`(secondTargetView.context).thenReturn(context)
488         `when`(secondTargetView.width).thenReturn(targetSize) // width = 200
489         `when`(secondTargetView.height).thenReturn(targetSize) // height = 200
490         doAnswer { invocation ->
491             (invocation.arguments[0] as Runnable).run()
492             true
493         }.`when`(secondTargetView).post(ArgumentMatchers.any())
494         doAnswer { invocation ->
495             (invocation.arguments[0] as IntArray).also { location ->
496                 // Return the top left of the target.
497                 location[0] = secondTargetCenterX - targetSize / 2 // x = 0
498                 location[1] = secondTargetCenterY - targetSize / 2 // y = 800
499             }
500         }.`when`(secondTargetView).getLocationOnScreen(ArgumentMatchers.any())
501 
502         return magnetizedObject.addTarget(secondTargetView, magneticFieldRadius)
503     }
504 
505     /**
506      * Return a MotionEvent at the given coordinates, with the given action (or MOVE by default).
507      * The event's time fields will be incremented by 10ms each time this is called, so tha
508      * VelocityTracker works.
509      */
510     private fun getMotionEvent(
511         x: Int,
512         y: Int,
513         action: Int = MotionEvent.ACTION_MOVE
514     ): MotionEvent {
515         return MotionEvent.obtain(time, time, action, x.toFloat(), y.toFloat(), 0)
516                 .also { time += timeStep }
517     }
518 
519     /** Dispatch all of the provided events to the target view. */
520     private fun dispatchMotionEvents(vararg events: MotionEvent) {
521         events.forEach { magnetizedObject.maybeConsumeMotionEvent(it) }
522     }
523 
524     /** Prevents Kotlin from being mad that eq() is nullable. */
525     private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
526 }