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 }