1 /*
2  * Copyright (C) 2017 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 android.accessibilityservice.cts.utils;
18 
19 import static org.junit.Assert.assertTrue;
20 
21 import android.accessibility.cts.common.InstrumentedAccessibilityService;
22 import android.accessibilityservice.AccessibilityService.GestureResultCallback;
23 import android.accessibilityservice.GestureDescription;
24 import android.accessibilityservice.GestureDescription.StrokeDescription;
25 import android.graphics.Path;
26 import android.graphics.PointF;
27 import android.os.Bundle;
28 import android.util.Log;
29 import android.view.Display;
30 import android.view.MotionEvent;
31 import android.view.ViewConfiguration;
32 
33 import androidx.test.InstrumentationRegistry;
34 
35 import org.hamcrest.Description;
36 import org.hamcrest.Matcher;
37 import org.hamcrest.TypeSafeMatcher;
38 import org.junit.rules.TestRule;
39 import org.junit.runners.model.Statement;
40 
41 import java.util.Random;
42 import java.util.concurrent.CompletableFuture;
43 
44 public class GestureUtils {
45 
46     public static final String LOG_TAG = "GestureUtils";
47 
48 
49     public static final Matcher<MotionEvent> IS_ACTION_DOWN =
50             new MotionEventActionMatcher(MotionEvent.ACTION_DOWN);
51     public static final Matcher<MotionEvent> IS_ACTION_POINTER_DOWN =
52             new MotionEventActionMatcher(MotionEvent.ACTION_POINTER_DOWN);
53     public static final Matcher<MotionEvent> IS_ACTION_UP =
54             new MotionEventActionMatcher(MotionEvent.ACTION_UP);
55     public static final Matcher<MotionEvent> IS_ACTION_POINTER_UP =
56             new MotionEventActionMatcher(MotionEvent.ACTION_POINTER_UP);
57     public static final Matcher<MotionEvent> IS_ACTION_CANCEL =
58             new MotionEventActionMatcher(MotionEvent.ACTION_CANCEL);
59     public static final Matcher<MotionEvent> IS_ACTION_MOVE =
60             new MotionEventActionMatcher(MotionEvent.ACTION_MOVE);
61 
62     // Bounds for the amount of time between taps
63     private static final long STROKE_TIME_GAP_MS_MIN = 1;
64     private static final long STROKE_TIME_GAP_MS_DEFAULT = 40;
65     private static final long STROKE_TIME_GAP_MS_MAX = ViewConfiguration.getDoubleTapTimeout() - 1;
66 
67     // Bounds for the duration of a tap.
68     static final long TAP_DURATION_MS_MIN = 1;
69     static final long TAP_DURATION_MS_MAX = ViewConfiguration.getTapTimeout();
70     static final long TAP_DURATION_MS_DEFAULT = ViewConfiguration.getTapTimeout();
71     private static Random sRandom = null;
72 
73     private static boolean sShouldRandomize = false;
74     // We generate the random seed later in randomize() and store it here to enable users to
75     // reproduce randomized test failures.
76     private static long sRandomSeed = 0;
77     private static long sStrokeGapTimeMs = STROKE_TIME_GAP_MS_DEFAULT;
78     private static long sTapDuration = TAP_DURATION_MS_DEFAULT;
79 
GestureUtils()80     private GestureUtils() {}
81 
dispatchGesture( InstrumentedAccessibilityService service, GestureDescription gesture)82     public static CompletableFuture<Void> dispatchGesture(
83             InstrumentedAccessibilityService service, GestureDescription gesture) {
84         CompletableFuture<Void> result = new CompletableFuture<>();
85         GestureResultCallback callback =
86                 new GestureResultCallback() {
87                     @Override
88                     public void onCompleted(GestureDescription gestureDescription) {
89                         result.complete(null);
90                     }
91 
92                     @Override
93                     public void onCancelled(GestureDescription gestureDescription) {
94                         result.cancel(false);
95                     }
96                 };
97         service.runOnServiceSync(
98                 () -> {
99                     if (!service.dispatchGesture(gesture, callback, null)) {
100                         result.completeExceptionally(new IllegalStateException());
101                     }
102                 });
103         return result;
104     }
105 
pointerDown(PointF point)106     public static StrokeDescription pointerDown(PointF point) {
107         return new StrokeDescription(path(point), 0, ViewConfiguration.getTapTimeout(), true);
108     }
109 
pointerUp(StrokeDescription lastStroke)110     public static StrokeDescription pointerUp(StrokeDescription lastStroke) {
111         return lastStroke.continueStroke(
112                 path(lastPointOf(lastStroke)),
113                 endTimeOf(lastStroke),
114                 ViewConfiguration.getTapTimeout(),
115                 false);
116     }
117 
lastPointOf(StrokeDescription stroke)118     public static PointF lastPointOf(StrokeDescription stroke) {
119         float[] p = stroke.getPath().approximate(0.3f);
120         return new PointF(p[p.length - 2], p[p.length - 1]);
121     }
122 
click(PointF point)123     public static StrokeDescription click(PointF point) {
124         return new StrokeDescription(path(point), 0, sTapDuration);
125     }
126 
longClick(PointF point)127     public static StrokeDescription longClick(PointF point) {
128         return new StrokeDescription(path(point), 0, ViewConfiguration.getLongPressTimeout() * 3);
129     }
130 
swipe(PointF from, PointF to)131     public static StrokeDescription swipe(PointF from, PointF to) {
132         return swipe(from, to, ViewConfiguration.getTapTimeout());
133     }
134 
swipe(PointF from, PointF to, long duration)135     public static StrokeDescription swipe(PointF from, PointF to, long duration) {
136         return new StrokeDescription(path(from, to), 0, duration);
137     }
138 
139     /**
140      * Simulates a touch exploration swipe that is interrupted partway through for a specified
141      * amount of time, and then continued.
142      */
interruptedSwipe(PointF from, PointF to, long duration)143     public static GestureDescription interruptedSwipe(PointF from, PointF to, long duration) {
144         GestureDescription.Builder builder = new GestureDescription.Builder();
145         long time = 0;
146         PointF midpoint = new PointF((from.x + to.x) / 2.0f, (from.y + to.y) / 2.0f);
147         StrokeDescription swipe1 = new StrokeDescription(path(from, midpoint), 0, duration / 2);
148         builder.addStroke(swipe1);
149         time += swipe1.getDuration() + sStrokeGapTimeMs;
150         StrokeDescription swipe2 = startingAt(time, swipe(midpoint, to, duration / 2));
151         builder.addStroke(swipe2);
152         return builder.build();
153     }
154 
drag(StrokeDescription from, PointF to)155     public static StrokeDescription drag(StrokeDescription from, PointF to) {
156         return from.continueStroke(
157                 path(lastPointOf(from), to),
158                 endTimeOf(from),
159                 ViewConfiguration.getTapTimeout(),
160                 true);
161     }
162 
path(PointF first, PointF... rest)163     public static Path path(PointF first, PointF... rest) {
164         Path path = new Path();
165         path.moveTo(first.x, first.y);
166         for (PointF point : rest) {
167             path.lineTo(point.x, point.y);
168         }
169         return path;
170     }
171 
startingAt(long timeMs, StrokeDescription prototype)172     public static StrokeDescription startingAt(long timeMs, StrokeDescription prototype) {
173         return new StrokeDescription(
174                 prototype.getPath(), timeMs, prototype.getDuration(), prototype.willContinue());
175     }
176 
endTimeOf(StrokeDescription stroke)177     public static long endTimeOf(StrokeDescription stroke) {
178         return stroke.getStartTime() + stroke.getDuration();
179     }
180 
distance(PointF a, PointF b)181     public static float distance(PointF a, PointF b) {
182         if (a == null) throw new NullPointerException();
183         if (b == null) throw new NullPointerException();
184         return (float) Math.hypot(a.x - b.x, a.y - b.y);
185     }
186 
add(PointF a, float x, float y)187     public static PointF add(PointF a, float x, float y) {
188         return new PointF(a.x + x, a.y + y);
189     }
190 
add(PointF a, PointF b)191     public static PointF add(PointF a, PointF b) {
192         return add(a, b.x, b.y);
193     }
194 
diff(PointF a, PointF b)195     public static PointF diff(PointF a, PointF b) {
196         return add(a, -b.x, -b.y);
197     }
198 
negate(PointF p)199     public static PointF negate(PointF p) {
200         return times(-1, p);
201     }
202 
times(float mult, PointF p)203     public static PointF times(float mult, PointF p) {
204         return new PointF(p.x * mult, p.y * mult);
205     }
206 
length(PointF p)207     public static float length(PointF p) {
208         return (float) Math.hypot(p.x, p.y);
209     }
210 
ceil(PointF p)211     public static PointF ceil(PointF p) {
212         return new PointF((float) Math.ceil(p.x), (float) Math.ceil(p.y));
213     }
214 
doubleTap(PointF point)215     public static GestureDescription doubleTap(PointF point) {
216         return doubleTap(point, Display.DEFAULT_DISPLAY);
217     }
218 
219     /** Generates a double-tap gesture. */
doubleTap(PointF point, int displayId)220     public static GestureDescription doubleTap(PointF point, int displayId) {
221         return multiTap(point, 2, 0, displayId);
222     }
223 
tripleTap(PointF point)224     public static GestureDescription tripleTap(PointF point) {
225         return tripleTap(point, Display.DEFAULT_DISPLAY);
226     }
227 
228     /** Generates a triple-tap gesture. */
tripleTap(PointF point, int displayId)229     public static GestureDescription tripleTap(PointF point, int displayId) {
230         return multiTap(point, 3, 0, displayId);
231     }
232 
multiTap(PointF point, int taps)233     public static GestureDescription multiTap(PointF point, int taps) {
234         return multiTap(point, taps, 0);
235     }
236 
multiTap(PointF point, int taps, int slop)237     public static GestureDescription multiTap(PointF point, int taps, int slop) {
238         return multiTap(point, taps, 0, Display.DEFAULT_DISPLAY);
239     }
240 
241     /** Generates a single-finger multi-tap gesture. */
multiTap(PointF point, int taps, int slop, int displayId)242     public static GestureDescription multiTap(PointF point, int taps, int slop, int displayId) {
243         GestureDescription.Builder builder = new GestureDescription.Builder();
244         builder.setDisplayId(displayId);
245         long time = 0;
246         if (taps > 0) {
247             // Place first tap on the point itself.
248             // Subsequent taps will be offset somewhere within slop radius.
249             // If slop is 0 subsequent taps will also be on the point itself.
250             StrokeDescription stroke = click(point);
251             builder.addStroke(stroke);
252             time += stroke.getDuration() + sStrokeGapTimeMs;
253             for (int i = 1; i < taps; i++) {
254                 stroke = click(getPointWithinSlop(point, slop));
255                 builder.addStroke(startingAt(time, stroke));
256                 time += stroke.getDuration() + sStrokeGapTimeMs;
257             }
258         }
259         return builder.build();
260     }
261 
doubleTapAndHold(PointF point)262     public static GestureDescription doubleTapAndHold(PointF point) {
263         return doubleTapAndHold(point, Display.DEFAULT_DISPLAY);
264     }
265 
266     /** Generates a single-finger double-tap and hold gesture. */
doubleTapAndHold(PointF point, int displayId)267     public static GestureDescription doubleTapAndHold(PointF point, int displayId) {
268         GestureDescription.Builder builder = new GestureDescription.Builder();
269         builder.setDisplayId(displayId);
270         StrokeDescription tap1 = click(point);
271         StrokeDescription tap2 = startingAt(endTimeOf(tap1) + sStrokeGapTimeMs, longClick(point));
272         builder.addStroke(tap1);
273         builder.addStroke(tap2);
274         return builder.build();
275     }
276 
getGestureBuilder( int displayId, StrokeDescription... strokes)277     public static GestureDescription.Builder getGestureBuilder(
278             int displayId, StrokeDescription... strokes) {
279         GestureDescription.Builder builder = new GestureDescription.Builder();
280         builder.setDisplayId(displayId);
281         for (StrokeDescription s : strokes) builder.addStroke(s);
282         return builder;
283     }
284 
285     private static class MotionEventActionMatcher extends TypeSafeMatcher<MotionEvent> {
286         int mAction;
287 
MotionEventActionMatcher(int action)288         MotionEventActionMatcher(int action) {
289             super();
290             mAction = action;
291         }
292 
293         @Override
matchesSafely(MotionEvent motionEvent)294         protected boolean matchesSafely(MotionEvent motionEvent) {
295             return motionEvent.getActionMasked() == mAction;
296         }
297 
298         @Override
describeTo(Description description)299         public void describeTo(Description description) {
300             description.appendText("Matching to action " + MotionEvent.actionToString(mAction));
301         }
302 
303         @Override
describeMismatchSafely(MotionEvent event, Description description)304         public void describeMismatchSafely(MotionEvent event, Description description) {
305             description.appendText(
306                     "received " + MotionEvent.actionToString(event.getActionMasked()));
307         }
308     }
309 
isAtPoint(final PointF point)310     public static Matcher<MotionEvent> isAtPoint(final PointF point) {
311         return isAtPoint(point, 0.01f);
312     }
313 
isAtPoint(final PointF point, final float tol)314     public static Matcher<MotionEvent> isAtPoint(final PointF point, final float tol) {
315         return new TypeSafeMatcher<MotionEvent>() {
316             @Override
317             protected boolean matchesSafely(MotionEvent event) {
318                 return Math.hypot(event.getX() - point.x, event.getY() - point.y) <= tol;
319             }
320 
321             @Override
322             public void describeTo(Description description) {
323                 description.appendText("Matching to point " + point);
324             }
325 
326             @Override
327             public void describeMismatchSafely(MotionEvent event, Description description) {
328                 description.appendText("received (" + event.getX() + ", " + event.getY() + ")");
329             }
330         };
331     }
332 
333     public static Matcher<MotionEvent> isRawAtPoint(final PointF point) {
334         return isRawAtPoint(point, 0.01f);
335     }
336 
337     public static Matcher<MotionEvent> isRawAtPoint(final PointF point, final float tol) {
338         return new TypeSafeMatcher<MotionEvent>() {
339             @Override
340             protected boolean matchesSafely(MotionEvent event) {
341                 return Math.hypot(event.getRawX() - point.x, event.getRawY() - point.y) <= tol;
342             }
343 
344             @Override
345             public void describeTo(Description description) {
346                 description.appendText("Matching to point " + point);
347             }
348 
349             @Override
350             public void describeMismatchSafely(MotionEvent event, Description description) {
351                 description.appendText(
352                         "received (" + event.getRawX() + ", " + event.getRawY() + ")");
353             }
354         };
355     }
356 
357     public static PointF getPointWithinSlop(PointF point, int slop) {
358         return add(point, slop / 2, 0);
359     }
360 
361     /**
362      * Simulates a user placing one finger on the screen for a specified amount of time and then
363      * multi-tapping with a second finger.
364      *
365      * @param explorePoint Where to place the first finger.
366      * @param tapPoint Where to tap with the second finger.
367      * @param taps The number of second-finger taps.
368      * @param waitTime How long to hold the first finger before tapping with the second finger.
369      */
370     public static GestureDescription secondFingerMultiTap(
371             PointF explorePoint, PointF tapPoint, int taps, int waitTime) {
372         GestureDescription.Builder builder = new GestureDescription.Builder();
373         long time = waitTime;
374         for (int i = 0; i < taps; i++) {
375             StrokeDescription stroke = click(tapPoint);
376             builder.addStroke(startingAt(time, stroke));
377             time += stroke.getDuration();
378             time += ViewConfiguration.getDoubleTapTimeout() / 3;
379         }
380         builder.addStroke(swipe(explorePoint, explorePoint, time));
381         return builder.build();
382     }
383 
384     /**
385      * Simulates a user placing multiple fingers on the specified screen
386      * and then multi-tapping with these fingers.
387      *
388      * The location of fingers based on <code>basePoint<code/> are shifted by <code>delta<code/>.
389      * Like (baseX, baseY), (baseX + deltaX, baseY + deltaY), and so on.
390      *
391      * @param basePoint Where to place the first finger.
392      * @param delta Offset to basePoint where to place the 2nd or 3rd finger.
393      * @param fingerCount The number of fingers.
394      * @param tapCount The number of taps to fingers.
395      * @param slop Slop range the finger tapped.
396      * @param displayId Which display to dispatch the gesture.
397      */
398     public static GestureDescription multiFingerMultiTap(
399             PointF basePoint,
400             PointF delta,
401             int fingerCount,
402             int tapCount,
403             int slop,
404             int displayId) {
405         assertTrue(fingerCount >= 2);
406         assertTrue(tapCount > 0);
407         final int strokeCount = fingerCount * tapCount;
408         final PointF[] pointers = new PointF[fingerCount];
409         final StrokeDescription[] strokes = new StrokeDescription[strokeCount];
410 
411         // The first tap
412         for (int i = 0; i < fingerCount; i++) {
413             pointers[i] = add(basePoint, times(i, delta));
414             strokes[i] = click(pointers[i]);
415         }
416         // The rest of taps
417         for (int tapIndex = 1; tapIndex < tapCount; tapIndex++) {
418             for (int i = 0; i < fingerCount; i++) {
419                 final StrokeDescription lastStroke = strokes[(tapIndex - 1) * fingerCount + i];
420                 final long nextStartTime = endTimeOf(lastStroke) + sStrokeGapTimeMs;
421                 final int nextIndex = tapIndex * fingerCount + i;
422                 pointers[i] = getPointWithinSlop(pointers[i], slop);
423                 strokes[nextIndex] = startingAt(nextStartTime, click(pointers[i]));
424             }
425         }
426         return getGestureBuilder(displayId, strokes).build();
427     }
428 
429     /**
430      * Simulates a user placing multiple fingers on the specified screen
431      * and then multi-tapping and holding with these fingers.
432      *
433      * The location of fingers based on <code>basePoint<code/> are shifted by <code>delta<code/>.
434      * Like (baseX, baseY), (baseX + deltaX, baseY + deltaY), and so on.
435      *
436      * @param basePoint Where to place the first finger.
437      * @param delta Offset to basePoint where to place the 2nd or 3rd finger.
438      * @param fingerCount The number of fingers.
439      * @param tapCount The number of taps to fingers.
440      * @param slop Slop range the finger tapped.
441      * @param displayId Which display to dispatch the gesture.
442      */
443     public static GestureDescription multiFingerMultiTapAndHold(
444             PointF basePoint,
445             PointF delta,
446             int fingerCount,
447             int tapCount,
448             int slop,
449             int displayId) {
450         assertTrue(fingerCount >= 2);
451         assertTrue(tapCount > 0);
452         final int strokeCount = fingerCount * tapCount;
453         final PointF[] pointers = new PointF[fingerCount];
454         final StrokeDescription[] strokes = new StrokeDescription[strokeCount];
455 
456         // The first tap
457         for (int i = 0; i < fingerCount; i++) {
458             pointers[i] = add(basePoint, times(i, delta));
459             if (tapCount == 1) {
460                 strokes[i] = longClick(pointers[i]);
461             } else {
462                 strokes[i] = click(pointers[i]);
463             }
464         }
465         // The rest of taps
466         for (int tapIndex = 1; tapIndex < tapCount; tapIndex++) {
467             for (int i = 0; i < fingerCount; i++) {
468                 final StrokeDescription lastStroke = strokes[(tapIndex - 1) * fingerCount + i];
469                 final long nextStartTime = endTimeOf(lastStroke) + sStrokeGapTimeMs;
470                 final int nextIndex = tapIndex * fingerCount + i;
471                 pointers[i] = getPointWithinSlop(pointers[i], slop);
472                 if (tapIndex + 1 == tapCount) {
473                     // Last tap so do long click.
474                     strokes[nextIndex] = startingAt(nextStartTime, longClick(pointers[i]));
475                 } else {
476                     strokes[nextIndex] = startingAt(nextStartTime, click(pointers[i]));
477                 }
478             }
479         }
480         return getGestureBuilder(displayId, strokes).build();
481     }
482 
483     /** Randomizes the values governing gesture creation. */
484     public static void randomize() {
485         Bundle args = InstrumentationRegistry.getArguments();
486         String shouldRandomize = args.getString("randomize");
487         if (shouldRandomize == null) {
488             return;
489         }
490         sShouldRandomize = Boolean.parseBoolean(shouldRandomize);
491         if (!sShouldRandomize) {
492             return;
493         }
494         String seed = args.getString("randomSeed");
495         if (seed != null) {
496             sRandomSeed = Long.parseLong(seed);
497             sRandom = new Random(sRandomSeed);
498         } else {
499             // Generate a random seed then store it.
500             sRandomSeed = new Random().nextLong();
501             sRandom = new Random(sRandomSeed);
502         }
503         Log.d(LOG_TAG, "Randomized values. Seed = " + sRandomSeed);
504         sStrokeGapTimeMs =
505                 sRandom.nextLong(STROKE_TIME_GAP_MS_MAX - STROKE_TIME_GAP_MS_MIN)
506                         + STROKE_TIME_GAP_MS_MIN;
507         sTapDuration =
508                 sRandom.nextLong(TAP_DURATION_MS_MAX - TAP_DURATION_MS_MIN) + TAP_DURATION_MS_MIN;
509         Log.d(LOG_TAG, "Stroke gap time = " + sStrokeGapTimeMs);
510         Log.d(LOG_TAG, "Tap Duration = " + sTapDuration);
511     }
512 
513     /** Rule used to report values in the failure message for easier bug reporting. */
514     public static class DumpOnFailureRule implements TestRule {
515 
516         @Override
517         public Statement apply(Statement base, org.junit.runner.Description description) {
518             return new DumpOnFailureStatement(base);
519         }
520 
521         class DumpOnFailureStatement extends Statement {
522             Statement mBase;
523 
524             DumpOnFailureStatement(Statement base) {
525                 mBase = base;
526             }
527 
528             @Override
529             public void evaluate() throws Throwable {
530                 try {
531                     mBase.evaluate();
532                 } catch (Throwable throwable) {
533                     if (sShouldRandomize) {
534                         // Give instructions on how to reproduce this behavior
535                         String message = createFailureMessage(throwable.getMessage());
536                         throw new Exception(message, throwable);
537                     } else {
538                         throw throwable;
539                     }
540                 }
541             }
542         }
543 
544         private String createFailureMessage(String originalMessage) {
545             StringBuilder message = new StringBuilder();
546             message.append(originalMessage)
547                     .append("\nFor convenience, the randomized values are as follows:")
548                     .append("\nStroke gap time: ")
549                     .append(sStrokeGapTimeMs)
550                     .append("\nTap duration: ")
551                     .append(sTapDuration)
552                     .append("\nTo reproduce this failure using atest,")
553                     .append(" run this test with the options")
554                     .append("\n-- --test-arg")
555                     .append(" com.android.tradefed.testtype.AndroidJUnitTest:")
556                     .append("instrumentation-arg:randomize:=")
557                     .append(sShouldRandomize)
558                     .append(" --test-arg")
559                     .append(" com.android.tradefed.testtype.AndroidJUnitTest:")
560                     .append("instrumentation-arg:randomSeed:=")
561                     .append(sRandomSeed);
562             return message.toString();
563         }
564 
565     }
566 }
567