1 /* 2 * Copyright 2018 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 17 package androidx.testutils; 18 19 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast; 20 21 import android.app.Instrumentation; 22 import android.graphics.Rect; 23 import android.os.Handler; 24 import android.os.Looper; 25 import android.os.SystemClock; 26 import android.view.MotionEvent; 27 import android.view.View; 28 import android.view.ViewGroup; 29 30 import androidx.annotation.NonNull; 31 import androidx.test.espresso.InjectEventSecurityException; 32 import androidx.test.espresso.PerformException; 33 import androidx.test.espresso.UiController; 34 import androidx.test.espresso.ViewAction; 35 import androidx.test.espresso.action.CoordinatesProvider; 36 37 import org.hamcrest.Matcher; 38 39 import java.util.ArrayList; 40 import java.util.List; 41 import java.util.Locale; 42 import java.util.concurrent.CountDownLatch; 43 44 /** 45 * <p>A {@link ViewAction} that swipes the view on which the action is performed to the given 46 * top-left coordinates. It is required that the view moves along with the swipe, as it would list 47 * views (e.g., a RecyclerView). Can be instantiated and run independently of Espresso as well, by 48 * {@link #initialize(View) initializing} and then {@link #perform(Instrumentation) performing} the 49 * action.</p> 50 * 51 * <p>Provides two different ways to provide the target coordinates: either the center of the view's 52 * parent ({@link #swipeToCenter()} and {@link #flingToCenter()}), or fixed coordinates ({@link 53 * #swipeTo(float[])} and {@link #flingTo(float[])})</p> 54 */ 55 public class SwipeToLocation implements ViewAction { 56 57 private static CoordinatesProvider sCenterInParent = new CoordinatesProvider() { 58 @Override 59 public float[] calculateCoordinates(View view) { 60 View parent = (View) view.getParent(); 61 62 int horizontalPadding = parent.getPaddingLeft() + parent.getPaddingRight(); 63 int verticalPadding = parent.getPaddingTop() + parent.getPaddingBottom(); 64 int widthParent = parent.getWidth() - horizontalPadding; 65 int heightParent = parent.getHeight() - verticalPadding; 66 int widthView = view.getWidth(); 67 int heightView = view.getHeight(); 68 int leftMarginView = 0; 69 int topMarginView = 0; 70 71 ViewGroup.LayoutParams params = view.getLayoutParams(); 72 if (params instanceof ViewGroup.MarginLayoutParams) { 73 ViewGroup.MarginLayoutParams margins = (ViewGroup.MarginLayoutParams) params; 74 leftMarginView = margins.leftMargin; 75 topMarginView = margins.topMargin; 76 widthView += margins.leftMargin + margins.rightMargin; 77 heightView += margins.topMargin + margins.bottomMargin; 78 } 79 80 float[] coords = new float[2]; 81 //noinspection IntegerDivisionInFloatingPointContext 82 coords[X] = (widthParent - widthView) / 2 + parent.getPaddingLeft() + leftMarginView; 83 //noinspection IntegerDivisionInFloatingPointContext 84 coords[Y] = (heightParent - heightView) / 2 + parent.getPaddingTop() + topMarginView; 85 return coords; 86 } 87 88 @NonNull 89 @Override 90 public String toString() { 91 return "center in parent"; 92 } 93 }; 94 95 private static class FixedCoordinates implements CoordinatesProvider { 96 private final float[] mCoordinates; 97 FixedCoordinates(float[] coordinates)98 FixedCoordinates(float[] coordinates) { 99 mCoordinates = coordinates; 100 } 101 102 @Override calculateCoordinates(View view)103 public float[] calculateCoordinates(View view) { 104 return mCoordinates; 105 } 106 107 @NonNull 108 @Override toString()109 public String toString() { 110 return String.format(Locale.US, "fixed coordinates (%f, %f)", 111 mCoordinates[X], mCoordinates[Y]); 112 } 113 } 114 115 private static final int X = 0; 116 private static final int Y = 1; 117 118 private final CoordinatesProvider mCoordinatesProvider; 119 private final int mDuration; 120 private final int mSteps; 121 122 // The view to move and to swipe on 123 @SuppressWarnings("WeakerAccess") // package-protected to prevent synthetic access 124 View mView; 125 // The pointer location where we start the swipe, must be on the view 126 private float[] mSwipeStart; 127 // The view location where we want the view to end 128 private float[] mTargetViewLocation; 129 SwipeToLocation(CoordinatesProvider coordinatesProvider, int duration, int steps)130 private SwipeToLocation(CoordinatesProvider coordinatesProvider, int duration, int steps) { 131 mCoordinatesProvider = coordinatesProvider; 132 mDuration = duration; 133 mSteps = steps; 134 } 135 136 /** 137 * Swipe the view to the given target location. Swiping takes 1 second to complete. 138 * 139 * @param targetLocation The top-left target coordinates of the view 140 * @return The ViewAction to use in {@link 141 * androidx.test.espresso.ViewInteraction#perform(ViewAction...)} 142 */ swipeTo(float[] targetLocation)143 public static SwipeToLocation swipeTo(float[] targetLocation) { 144 return new SwipeToLocation(new FixedCoordinates(targetLocation), 1000, 10); 145 } 146 147 /** 148 * Fling the view to the given target location. Flinging takes 0.1 seconds to complete. 149 * 150 * @param targetLocation The top-left target coordinates of the view 151 * @return The ViewAction to use in {@link 152 * androidx.test.espresso.ViewInteraction#perform(ViewAction...)} 153 */ flingTo(float[] targetLocation)154 public static SwipeToLocation flingTo(float[] targetLocation) { 155 return new SwipeToLocation(new FixedCoordinates(targetLocation), 100, 10); 156 } 157 158 /** 159 * Swipe the view to the center of its parent. Swiping takes 1 second to complete. 160 * 161 * @return The ViewAction to use in {@link 162 * androidx.test.espresso.ViewInteraction#perform(ViewAction...)} 163 */ swipeToCenter()164 public static SwipeToLocation swipeToCenter() { 165 return new SwipeToLocation(sCenterInParent, 1000, 10); 166 } 167 168 /** 169 * Fling the view to the center of its parent. Flinging takes 0.1 seconds to complete. 170 * 171 * @return The ViewAction to use in {@link 172 * androidx.test.espresso.ViewInteraction#perform(ViewAction...)} 173 */ flingToCenter()174 public static SwipeToLocation flingToCenter() { 175 return new SwipeToLocation(sCenterInParent, 100, 10); 176 } 177 178 @Override getConstraints()179 public Matcher<View> getConstraints() { 180 return isDisplayingAtLeast(10); 181 } 182 183 @Override getDescription()184 public String getDescription() { 185 return String.format(Locale.US, "Swiping view to location %s", mCoordinatesProvider); 186 } 187 188 /** 189 * Sets the action up to run on the given view. 190 * 191 * @param view The View that is moved and on which the swipe is performed 192 */ initialize(@onNull View view)193 public void initialize(@NonNull View view) { 194 this.mView = view; 195 mSwipeStart = getCenterOfView(view); 196 mTargetViewLocation = mCoordinatesProvider.calculateCoordinates(view); 197 } 198 199 @Override perform(UiController uiController, View view)200 public void perform(UiController uiController, View view) { 201 initialize(view); 202 performWithMotionInjector(new UiFacadeWithUiController(uiController)); 203 } 204 205 /** 206 * Performs this action manually instead of as a ViewAction. Must not be called from the main 207 * thread. Useful if performing the swipe as a ViewAction doesn't work because Espresso waits 208 * until the main thread is idle while you actually want to execute it now. 209 * 210 * @param instrumentation The Instrumentation object used to inject MotionEvents 211 */ perform(Instrumentation instrumentation)212 public void perform(Instrumentation instrumentation) { 213 if (mView == null || mSwipeStart == null || mTargetViewLocation == null) { 214 throwWith(new IllegalStateException("SwipeToLocation must be initialized with a View " 215 + "first. See SwipeToLocation.initialize(View view)")); 216 } 217 performWithMotionInjector(new UiFacadeWithInstrumentation(instrumentation)); 218 } 219 performWithMotionInjector(UiFacade uiController)220 private void performWithMotionInjector(UiFacade uiController) { 221 sendOnlineSwipe(uiController, mDuration, mSteps); 222 } 223 224 /** 225 * Inject motion events to emulate a swipe to the target location. Instead of calculating all 226 * events up front and then injecting them one by one, perform the required number of steps and 227 * determine the distance to cover in the current step based on the current distance of the view 228 * to the target. This makes it robust against movements of the view during the event sequence. 229 * This is for example likely to happen between the down event and the first move event if we're 230 * interrupting a smooth scroll. 231 * 232 * @param uiController The controller to inject the motion events with 233 * @param duration The duration in milliseconds of the swipe gesture 234 * @param steps The number of move motion events that will be sent for the gesture 235 */ sendOnlineSwipe(UiFacade uiController, int duration, int steps)236 private void sendOnlineSwipe(UiFacade uiController, int duration, int steps) { 237 final long startTime = SystemClock.uptimeMillis(); 238 long eventTime = startTime; 239 final float[] pointerLocation = new float[]{mSwipeStart[X], mSwipeStart[Y]}; 240 final float[] viewLocation = new float[2]; 241 final float[] nextViewLocation = new float[2]; 242 final List<MotionEvent> events = new ArrayList<>(); 243 final Runnable updateCoordinates = new Runnable() { 244 @Override 245 public void run() { 246 // Update the view coordinates on the UI thread so the view is in a stable state 247 getCurrentCoords(mView, viewLocation); 248 } 249 }; 250 try { 251 // Down event 252 MotionEvent downEvent = obtainDownEvent(startTime, pointerLocation); 253 events.add(downEvent); 254 injectMotionEvent(uiController, downEvent); 255 256 // Move events 257 for (int i = 1; i <= steps; i++) { 258 eventTime = startTime + duration * i / duration; 259 uiController.runOnUiThreadSync(updateCoordinates); 260 lerp(viewLocation, mTargetViewLocation, 1f / (steps - i + 1), nextViewLocation); 261 updatePointerLocation(pointerLocation, viewLocation, nextViewLocation); 262 263 MotionEvent moveEvent = obtainMoveEvent(startTime, eventTime, pointerLocation); 264 events.add(moveEvent); 265 injectMotionEvent(uiController, moveEvent); 266 } 267 268 // Up event 269 MotionEvent upEvent = obtainUpEvent(startTime, eventTime, pointerLocation); 270 events.add(upEvent); 271 injectMotionEvent(uiController, upEvent); 272 } finally { 273 for (MotionEvent event : events) { 274 event.recycle(); 275 } 276 } 277 } 278 obtainDownEvent(long time, float[] coord)279 private static MotionEvent obtainDownEvent(long time, float[] coord) { 280 return MotionEvent.obtain(time, time, 281 MotionEvent.ACTION_DOWN, coord[X], coord[Y], 0); 282 } 283 obtainMoveEvent(long startTime, long elapsedTime, float[] coord)284 private static MotionEvent obtainMoveEvent(long startTime, long elapsedTime, float[] coord) { 285 return MotionEvent.obtain(startTime, elapsedTime, 286 MotionEvent.ACTION_MOVE, coord[X], coord[Y], 0); 287 } 288 obtainUpEvent(long startTime, long elapsedTime, float[] coord)289 private static MotionEvent obtainUpEvent(long startTime, long elapsedTime, float[] coord) { 290 return MotionEvent.obtain(startTime, elapsedTime, 291 MotionEvent.ACTION_UP, coord[X], coord[Y], 0); 292 } 293 injectMotionEvent(UiFacade uiController, MotionEvent event)294 private static void injectMotionEvent(UiFacade uiController, MotionEvent event) { 295 while (event.getEventTime() - SystemClock.uptimeMillis() > 10) { 296 // Because the loopMainThreadForAtLeast is overkill for waiting, intentionally only 297 // call it with a smaller amount of milliseconds as best effort 298 uiController.loopMainThreadForAtLeast(10); 299 } 300 uiController.injectMotionEvent(event); 301 } 302 updatePointerLocation(float[] pointerLocation, float[] viewLocation, float[] nextViewLocation)303 private void updatePointerLocation(float[] pointerLocation, float[] viewLocation, 304 float[] nextViewLocation) { 305 pointerLocation[X] += nextViewLocation[X] - viewLocation[X]; 306 pointerLocation[Y] += nextViewLocation[Y] - viewLocation[Y]; 307 } 308 getCenterOfView(View view)309 private static float[] getCenterOfView(View view) { 310 Rect r = new Rect(); 311 view.getGlobalVisibleRect(r); 312 return new float[]{r.centerX(), r.centerY()}; 313 } 314 315 @SuppressWarnings("WeakerAccess") // package-protected to prevent synthetic access getCurrentCoords(View view, float[] out)316 static void getCurrentCoords(View view, float[] out) { 317 out[X] = view.getLeft(); 318 out[Y] = view.getTop(); 319 } 320 lerp(float[] from, float[] to, float f, float[] out)321 private static void lerp(float[] from, float[] to, float f, float[] out) { 322 out[X] = (int) (from[X] + (to[X] - from[X]) * f); 323 out[Y] = (int) (from[Y] + (to[Y] - from[Y]) * f); 324 } 325 326 @SuppressWarnings("WeakerAccess") // package-protected to prevent synthetic access throwWith(Throwable error)327 static void throwWith(Throwable error) { 328 throw new PerformException.Builder().withActionDescription("Perform swipe") 329 .withViewDescription("unknown").withCause(error).build(); 330 } 331 332 /** 333 * An interface to inject events and interact with the UI thread. This allows us to use either 334 * {@link UiController} when performed as a {@link ViewAction}, or use {@link Instrumentation} 335 * when performing the swipe action manually. 336 */ 337 private interface UiFacade { injectMotionEvent(@onNull MotionEvent event)338 void injectMotionEvent(@NonNull MotionEvent event); 339 loopMainThreadForAtLeast(long millisDelay)340 void loopMainThreadForAtLeast(long millisDelay); 341 runOnUiThreadSync(@onNull Runnable runnable)342 void runOnUiThreadSync(@NonNull Runnable runnable); 343 } 344 345 /** 346 * A {@link UiFacade} build from a {@link UiController}. Instantiated when {@link 347 * SwipeToLocation#perform(UiController, View)} is executed by Espresso. As Espresso runs 348 * perform() on the UI thread, all interactions with this implementation happen on the UI 349 * thread. 350 */ 351 private static class UiFacadeWithUiController implements UiFacade { 352 private final UiController mUiController; 353 UiFacadeWithUiController(UiController uiController)354 UiFacadeWithUiController(UiController uiController) { 355 mUiController = uiController; 356 } 357 358 @Override injectMotionEvent(@onNull MotionEvent event)359 public void injectMotionEvent(@NonNull MotionEvent event) { 360 try { 361 mUiController.injectMotionEvent(event); 362 } catch (InjectEventSecurityException e) { 363 throwWith(e); 364 } 365 } 366 367 @Override loopMainThreadForAtLeast(long millisDelay)368 public void loopMainThreadForAtLeast(long millisDelay) { 369 mUiController.loopMainThreadForAtLeast(millisDelay); 370 } 371 372 @Override runOnUiThreadSync(@onNull Runnable runnable)373 public void runOnUiThreadSync(@NonNull Runnable runnable) { 374 // We're already on the UI thread 375 runnable.run(); 376 } 377 } 378 379 /** 380 * A {@link UiFacade} build from a {@link Instrumentation}. Instantiated when {@link 381 * SwipeToLocation#perform(Instrumentation)} is called manually. It is assumed that interactions 382 * with this implementation happen from another thread than the UI thread. 383 */ 384 private static class UiFacadeWithInstrumentation implements UiFacade { 385 private final Instrumentation mInstrumentation; 386 private final Handler mHandler; 387 UiFacadeWithInstrumentation(Instrumentation instrumentation)388 UiFacadeWithInstrumentation(Instrumentation instrumentation) { 389 mInstrumentation = instrumentation; 390 mHandler = new Handler(Looper.getMainLooper()); 391 } 392 393 @Override injectMotionEvent(@onNull MotionEvent event)394 public void injectMotionEvent(@NonNull MotionEvent event) { 395 mInstrumentation.sendPointerSync(event); 396 } 397 398 @Override loopMainThreadForAtLeast(long millisDelay)399 public void loopMainThreadForAtLeast(long millisDelay) { 400 if (Looper.myLooper() != Looper.getMainLooper()) { 401 throw new IllegalStateException(UiFacadeWithInstrumentation.class.getSimpleName() 402 + " cannot loop the main thread from the main thread itself"); 403 } 404 if (millisDelay > 0) { 405 SystemClock.sleep(millisDelay); 406 } 407 } 408 409 @Override runOnUiThreadSync(@onNull final Runnable runnable)410 public void runOnUiThreadSync(@NonNull final Runnable runnable) { 411 final CountDownLatch latch = new CountDownLatch(1); 412 mHandler.post(new Runnable() { 413 @Override 414 public void run() { 415 runnable.run(); 416 latch.countDown(); 417 } 418 }); 419 try { 420 latch.await(); 421 } catch (InterruptedException e) { 422 throwWith(e); 423 } 424 } 425 } 426 } 427