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