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