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 }