1 /* 2 * Copyright (C) 2015 Google Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 * in compliance with the License. You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software distributed under the License 10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 * or implied. See the License for the specific language governing permissions and limitations under 12 * the License. 13 */ 14 15 package com.google.android.apps.common.testing.accessibility.framework.checks; 16 17 import static com.google.android.apps.common.testing.accessibility.framework.ViewHierarchyElementUtils.ABS_LIST_VIEW_CLASS_NAME; 18 import static com.google.android.apps.common.testing.accessibility.framework.ViewHierarchyElementUtils.WEB_VIEW_CLASS_NAME; 19 import static com.google.common.base.Preconditions.checkNotNull; 20 import static java.lang.Boolean.TRUE; 21 22 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType; 23 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheck; 24 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheckResult; 25 import com.google.android.apps.common.testing.accessibility.framework.HashMapResultMetadata; 26 import com.google.android.apps.common.testing.accessibility.framework.Parameters; 27 import com.google.android.apps.common.testing.accessibility.framework.ResultMetadata; 28 import com.google.android.apps.common.testing.accessibility.framework.replacements.Point; 29 import com.google.android.apps.common.testing.accessibility.framework.replacements.Rect; 30 import com.google.android.apps.common.testing.accessibility.framework.strings.StringManager; 31 import com.google.android.apps.common.testing.accessibility.framework.uielement.AccessibilityHierarchy; 32 import com.google.android.apps.common.testing.accessibility.framework.uielement.DisplayInfo; 33 import com.google.android.apps.common.testing.accessibility.framework.uielement.DisplayInfo.Metrics; 34 import com.google.android.apps.common.testing.accessibility.framework.uielement.ViewHierarchyElement; 35 import com.google.common.annotations.VisibleForTesting; 36 import java.util.ArrayList; 37 import java.util.List; 38 import java.util.Locale; 39 import org.checkerframework.checker.nullness.qual.Nullable; 40 41 /** 42 * Check ensuring touch targets have a minimum size, 48x48dp by default 43 * 44 * <p>This check takes into account and supports: 45 * 46 * <ul> 47 * <li>Use of {@link android.view.TouchDelegate} to extend the touchable region or hit-Rect of UI 48 * elements 49 * <li>UI elements with interactable ancestors 50 * <li>UI elements along the scrollable edge of containers 51 * <li>Clipping effects applied by ancestors' sizing 52 * <li>Touch targets at the screen edge or within IMEs, requiring a reduced size 53 * <li>Customization of the minimum threshold for required size 54 * </ul> 55 */ 56 public class TouchTargetSizeCheck extends AccessibilityHierarchyCheck { 57 58 /** Result when the view is not clickable. */ 59 public static final int RESULT_ID_NOT_CLICKABLE = 1; 60 /** Result when the view is not visible. */ 61 public static final int RESULT_ID_NOT_VISIBLE = 2; 62 /** Result when the view's height and width are both too small. */ 63 public static final int RESULT_ID_SMALL_TOUCH_TARGET_WIDTH_AND_HEIGHT = 3; 64 /** Result when the view's height is too small. */ 65 public static final int RESULT_ID_SMALL_TOUCH_TARGET_HEIGHT = 4; 66 /** Result when the view's width is too small. */ 67 public static final int RESULT_ID_SMALL_TOUCH_TARGET_WIDTH = 5; 68 /** 69 * Result when the view's height and width are both smaller than the user-defined touch target 70 * size. 71 */ 72 public static final int RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_WIDTH_AND_HEIGHT = 6; 73 /** Result when the view's height is smaller than the user-defined touch target size. */ 74 public static final int RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_HEIGHT = 7; 75 /** Result when the view's width is smaller than the user-defined touch target size. */ 76 public static final int RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_WIDTH = 8; 77 78 /** 79 * Result metadata key for a {@code boolean} which is {@code true} iff the view has a {@link 80 * android.view.TouchDelegate} that may be handling touches on the view's behalf, but that 81 * delegate's hit-Rect is not available. 82 */ 83 public static final String KEY_HAS_TOUCH_DELEGATE = "KEY_HAS_TOUCH_DELEGATE"; 84 /** 85 * Result metadata key for a {@code boolean} which is {@code true} iff the view has a {@link 86 * android.view.TouchDelegate} with a hit-Rect available. When this key is set to {@code true}, 87 * {@link #KEY_HIT_RECT_WIDTH} and {@link #KEY_HIT_RECT_HEIGHT} are also provided within the 88 * result metadata. 89 */ 90 public static final String KEY_HAS_TOUCH_DELEGATE_WITH_HIT_RECT = 91 "KEY_HAS_TOUCH_DELEGATE_WITH_HIT_RECT"; 92 /** 93 * Result metadata key for a {@code boolean} which is {@code true} iff the view has an ancestor 94 * (of a suitable size) which may be handling click actions on behalf of the view. 95 */ 96 public static final String KEY_HAS_CLICKABLE_ANCESTOR = "KEY_HAS_CLICKABLE_ANCESTOR"; 97 /** 98 * Result metadata key for a {@code boolean} which is {@code true} iff the view is determined to 99 * be touching the scrollable edge of a scrollable container. 100 */ 101 public static final String KEY_IS_AGAINST_SCROLLABLE_EDGE = "KEY_IS_AGAINST_SCROLLABLE_EDGE"; 102 /** 103 * Result metadata key for a {@code boolean} which is {@code true} iff the view has a reduced 104 * visible size because it is clipped by a parent view. When this key is set to {@code true}, 105 * {@link #KEY_NONCLIPPED_HEIGHT} and {@link #KEY_NONCLIPPED_WIDTH} are also provided within the 106 * result metadata. 107 */ 108 public static final String KEY_IS_CLIPPED_BY_ANCESTOR = "KEY_IS_CLIPPED_BY_ANCESTOR"; 109 /** 110 * Result metadata key for a {@code boolean} which is {@code true} when the view is detremined to 111 * originate from web content. 112 */ 113 public static final String KEY_IS_WEB_CONTENT = "KEY_IS_WEB_CONTENT"; 114 /** Result metadata key for the {@code int} height of the view. */ 115 public static final String KEY_HEIGHT = "KEY_HEIGHT"; 116 /** Result metadata key for the {@code int} width of the view. */ 117 public static final String KEY_WIDTH = "KEY_WIDTH"; 118 /** 119 * Result metadata key for the {@code int} height of the view not considering clipping effects 120 * applied by parent views. This value is populated only when {@link #KEY_IS_CLIPPED_BY_ANCESTOR} 121 * is set to {@code true}. 122 */ 123 public static final String KEY_NONCLIPPED_HEIGHT = "KEY_NONCLIPPED_HEIGHT"; 124 /** 125 * Result metadata key for the {@code int} width of the view not considering clipping effects 126 * applied by parent views. This value is populated only when {@link #KEY_IS_CLIPPED_BY_ANCESTOR} 127 * is set to {@code true}. 128 */ 129 public static final String KEY_NONCLIPPED_WIDTH = "KEY_NONCLIPPED_WIDTH"; 130 /** Result metadata key for the {@code int} required height of the view */ 131 public static final String KEY_REQUIRED_HEIGHT = "KEY_REQUIRED_HEIGHT"; 132 /** Result metadata key for the {@code int} required width of the view */ 133 public static final String KEY_REQUIRED_WIDTH = "KEY_REQUIRED_WIDTH"; 134 /** Result metadata key for the {@code int} user-defined minimum width of the view */ 135 public static final String KEY_CUSTOMIZED_REQUIRED_WIDTH = "KEY_CUSTOMIZED_REQUIRED_WIDTH"; 136 /** Result metadata key for the {@code int} user-defined minimum height of the view */ 137 public static final String KEY_CUSTOMIZED_REQUIRED_HEIGHT = "KEY_CUSTOMIZED_REQUIRED_HEIGHT"; 138 /** 139 * Result metadata key for the {@code int} conveying the width of the largest {@link 140 * android.view.TouchDelegate} hit-Rect of the view 141 */ 142 public static final String KEY_HIT_RECT_WIDTH = "KEY_HIT_RECT_WIDTH"; 143 /** 144 * Result metadata key for the {@code int} conveying the height of the largest {@link 145 * android.view.TouchDelegate} hit-Rect of the view 146 */ 147 public static final String KEY_HIT_RECT_HEIGHT = "KEY_HIT_RECT_HEIGHT"; 148 149 /** 150 * Value of android.view.accessibility.AccessibilityWindowInfo.TYPE_INPUT_METHOD. This avoids a 151 * dependency upon Android libraries. 152 */ 153 @VisibleForTesting static final int TYPE_INPUT_METHOD = 2; 154 155 /** 156 * Minimum height and width are set according to 157 * <a href="http://developer.android.com/design/patterns/accessibility.html"></a> 158 * 159 * With the modification that targets against the edge of the screen may be narrower. 160 */ 161 private static final int TOUCH_TARGET_MIN_HEIGHT = 48; 162 private static final int TOUCH_TARGET_MIN_WIDTH = 48; 163 private static final int TOUCH_TARGET_MIN_HEIGHT_ON_EDGE = 32; 164 private static final int TOUCH_TARGET_MIN_WIDTH_ON_EDGE = 32; 165 private static final int TOUCH_TARGET_MIN_HEIGHT_IME_CONTAINER = 32; 166 private static final int TOUCH_TARGET_MIN_WIDTH_IME_CONTAINER = 32; 167 168 @Override getHelpTopic()169 protected String getHelpTopic() { 170 return "7101858"; // Touch target size 171 } 172 173 @Override getCategory()174 public Category getCategory() { 175 return Category.TOUCH_TARGET_SIZE; 176 } 177 178 @Override runCheckOnHierarchy( AccessibilityHierarchy hierarchy, @Nullable ViewHierarchyElement fromRoot, @Nullable Parameters parameters)179 public List<AccessibilityHierarchyCheckResult> runCheckOnHierarchy( 180 AccessibilityHierarchy hierarchy, 181 @Nullable ViewHierarchyElement fromRoot, 182 @Nullable Parameters parameters) { 183 List<AccessibilityHierarchyCheckResult> results = new ArrayList<>(); 184 185 DisplayInfo defaultDisplay = hierarchy.getDeviceState().getDefaultDisplayInfo(); 186 DisplayInfo.Metrics metricsWithoutDecorations = defaultDisplay.getMetricsWithoutDecoration(); 187 List<? extends ViewHierarchyElement> viewsToEval = getElementsToEvaluate(fromRoot, hierarchy); 188 for (ViewHierarchyElement view : viewsToEval) { 189 if (!(TRUE.equals(view.isClickable()) 190 || TRUE.equals(view.isLongClickable()))) { 191 results.add(new AccessibilityHierarchyCheckResult( 192 this.getClass(), 193 AccessibilityCheckResultType.NOT_RUN, 194 view, 195 RESULT_ID_NOT_CLICKABLE, 196 null)); 197 continue; 198 } 199 200 if (!TRUE.equals(view.isVisibleToUser())) { 201 results.add(new AccessibilityHierarchyCheckResult( 202 this.getClass(), 203 AccessibilityCheckResultType.NOT_RUN, 204 view, 205 RESULT_ID_NOT_VISIBLE, 206 null)); 207 continue; 208 } 209 210 Rect bounds = view.getBoundsInScreen(); 211 Point requiredSize = getMinimumAllowableSizeForView(view, parameters); 212 float density = metricsWithoutDecorations.getDensity(); 213 int actualHeight = Math.round(bounds.getHeight() / density); 214 int actualWidth = Math.round(bounds.getWidth() / density); 215 216 if (!meetsRequiredSize(bounds, requiredSize, density)) { 217 // Before we know a view fails this check, we must check if another View may be handling 218 // touches on its behalf. One mechanism for this is a TouchDelegate. 219 boolean hasDelegate = false; 220 Rect largestDelegateHitRect = null; 221 // There are two approaches to detecting such a delegate. One (on Android Q+) allows us 222 // access to the hit-Rect. Since this is the most precise signal, we try to use this first. 223 if (hasTouchDelegateWithHitRects(view)) { 224 hasDelegate = true; 225 if (hasTouchDelegateOfRequiredSize(view, requiredSize, density)) { 226 // Emit no result if a delegate's hit-Rect is above the required size 227 continue; 228 } 229 // If no associated hit-Rect is of the required size, reference the largest one for 230 // inclusion in the result message. 231 largestDelegateHitRect = getLargestTouchDelegateHitRect(view); 232 } else { 233 // Without hit-Rects, another approach is to check (View) ancestors for the presence of 234 // any TouchDelegate, which indicates that the element may have its hit-Rect adjusted, 235 // but does not tell us what its size is. 236 hasDelegate = hasAncestorWithTouchDelegate(view); 237 } 238 // Another approach is to have the parent handle touches for smaller child views, such as a 239 // android.widget.Switch, which retains its clickable state for a "handle drag" effect. In 240 // these cases, the parent must perform the same action as the child, which is beyond the 241 // scope of this test. We append this important exception message to the result by setting 242 // KEY_HAS_CLICKABLE_ANCESTOR within the result metadata. 243 boolean hasClickableAncestor = hasQualifyingClickableAncestor(view, parameters); 244 // When evaluating a View-based hierarchy, we can check if the visible size of the view is 245 // less than the drawing (nonclipped) size, which indicates an ancestor may scroll, 246 // expand/collapse, or otherwise constrain the size of the clickable item. 247 boolean isClippedByAncestor = hasQualifyingClippingAncestor(view, requiredSize, density); 248 // Web content exposed through an AccessibilityNodeInfo-based hierarchy from WebView cannot 249 // precisely represent the clickable area for DOM elements in a number of cases. We reduce 250 // severity and append a message recommending manual testing when encountering WebView. 251 boolean isWebContent = hasWebViewAncestor(view); 252 253 // In each of these cases, with the exception of when we have precise hit-Rect coordinates, 254 // we cannot determine how exactly click actions are being handled by the underlying 255 // application, so to avoid false positives, we will demote ERROR to WARNING. 256 AccessibilityCheckResultType resultType = 257 ((hasDelegate && (largestDelegateHitRect == null)) 258 || hasClickableAncestor 259 || isClippedByAncestor 260 || isWebContent) 261 ? AccessibilityCheckResultType.WARNING 262 : AccessibilityCheckResultType.ERROR; 263 264 // We must also detect the case where an item is indicated as a small target because it 265 // appears along the scrollable edge of a scrolling container. In this case, we cannot 266 // determine the native nonclipped bounds of the view, so we demote to NOT_RUN. 267 boolean isAtScrollableEdge = view.isAgainstScrollableEdge(); 268 resultType = isAtScrollableEdge ? AccessibilityCheckResultType.NOT_RUN : resultType; 269 270 ResultMetadata resultMetadata = new HashMapResultMetadata(); 271 resultMetadata.putInt(KEY_HEIGHT, actualHeight); 272 resultMetadata.putInt(KEY_WIDTH, actualWidth); 273 if (hasDelegate) { 274 if (largestDelegateHitRect != null) { 275 resultMetadata.putBoolean(KEY_HAS_TOUCH_DELEGATE_WITH_HIT_RECT, true); 276 resultMetadata.putInt( 277 KEY_HIT_RECT_WIDTH, Math.round(largestDelegateHitRect.getWidth() / density)); 278 resultMetadata.putInt( 279 KEY_HIT_RECT_HEIGHT, Math.round(largestDelegateHitRect.getHeight() / density)); 280 } else { 281 resultMetadata.putBoolean(KEY_HAS_TOUCH_DELEGATE, true); 282 } 283 } 284 if (hasClickableAncestor) { 285 resultMetadata.putBoolean(KEY_HAS_CLICKABLE_ANCESTOR, true); 286 } 287 if (isAtScrollableEdge) { 288 resultMetadata.putBoolean(KEY_IS_AGAINST_SCROLLABLE_EDGE, true); 289 } 290 if (isClippedByAncestor) { 291 // If the view is clipped by an ancestor, add the nonclipped dimensions to metadata. 292 // The non-clipped height and width cannot be null if isClippedByAncestor is true. 293 resultMetadata.putBoolean(KEY_IS_CLIPPED_BY_ANCESTOR, true); 294 resultMetadata.putInt(KEY_NONCLIPPED_HEIGHT, checkNotNull(view.getNonclippedHeight())); 295 resultMetadata.putInt(KEY_NONCLIPPED_WIDTH, checkNotNull(view.getNonclippedWidth())); 296 } 297 if (isWebContent) { 298 resultMetadata.putBoolean(KEY_IS_WEB_CONTENT, true); 299 } 300 301 Integer customizedTouchTargetSize = 302 (parameters == null) ? null : parameters.getCustomTouchTargetSize(); 303 if (customizedTouchTargetSize != null) { 304 resultMetadata.putInt(KEY_CUSTOMIZED_REQUIRED_WIDTH, requiredSize.getX()); 305 resultMetadata.putInt(KEY_CUSTOMIZED_REQUIRED_HEIGHT, requiredSize.getY()); 306 } else { 307 resultMetadata.putInt(KEY_REQUIRED_HEIGHT, requiredSize.getY()); 308 resultMetadata.putInt(KEY_REQUIRED_WIDTH, requiredSize.getX()); 309 } 310 311 if ((actualHeight < requiredSize.getY()) && (actualWidth < requiredSize.getX())) { 312 // Neither wide enough nor tall enough 313 results.add( 314 new AccessibilityHierarchyCheckResult( 315 this.getClass(), 316 resultType, 317 view, 318 (customizedTouchTargetSize == null) 319 ? RESULT_ID_SMALL_TOUCH_TARGET_WIDTH_AND_HEIGHT 320 : RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_WIDTH_AND_HEIGHT, 321 resultMetadata)); 322 } else if (actualHeight < requiredSize.getY()) { 323 // Not tall enough 324 results.add( 325 new AccessibilityHierarchyCheckResult( 326 this.getClass(), 327 resultType, 328 view, 329 (customizedTouchTargetSize == null) 330 ? RESULT_ID_SMALL_TOUCH_TARGET_HEIGHT 331 : RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_HEIGHT, 332 resultMetadata)); 333 } else { 334 // Not wide enough 335 results.add( 336 new AccessibilityHierarchyCheckResult( 337 this.getClass(), 338 resultType, 339 view, 340 (customizedTouchTargetSize == null) 341 ? RESULT_ID_SMALL_TOUCH_TARGET_WIDTH 342 : RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_WIDTH, 343 resultMetadata)); 344 } 345 } 346 } 347 return results; 348 } 349 350 @Override getMessageForResultData( Locale locale, int resultId, @Nullable ResultMetadata metadata)351 public String getMessageForResultData( 352 Locale locale, int resultId, @Nullable ResultMetadata metadata) { 353 String generated = generateMessageForResultId(locale, resultId); 354 if (generated != null) { 355 return generated; 356 } 357 358 // For each of the following result IDs, metadata will have been set on the result. 359 checkNotNull(metadata); 360 StringBuilder builder = new StringBuilder(); 361 int requiredHeight = metadata.getInt(KEY_REQUIRED_HEIGHT, TOUCH_TARGET_MIN_HEIGHT); 362 int requiredWidth = metadata.getInt(KEY_REQUIRED_WIDTH, TOUCH_TARGET_MIN_WIDTH); 363 switch (resultId) { 364 case RESULT_ID_SMALL_TOUCH_TARGET_WIDTH_AND_HEIGHT: 365 builder.append(String.format(locale, 366 StringManager.getString(locale, "result_message_small_touch_target_width_and_height"), 367 metadata.getInt(KEY_WIDTH), metadata.getInt(KEY_HEIGHT), requiredWidth, 368 requiredHeight)); 369 appendMetadataStringsToMessageIfNeeded(locale, metadata, builder); 370 return builder.toString(); 371 case RESULT_ID_SMALL_TOUCH_TARGET_HEIGHT: 372 builder.append(String.format(locale, 373 StringManager.getString(locale, "result_message_small_touch_target_height"), 374 metadata.getInt(KEY_HEIGHT), requiredHeight)); 375 appendMetadataStringsToMessageIfNeeded(locale, metadata, builder); 376 return builder.toString(); 377 case RESULT_ID_SMALL_TOUCH_TARGET_WIDTH: 378 builder.append(String.format(locale, 379 StringManager.getString(locale, "result_message_small_touch_target_width"), 380 metadata.getInt(KEY_WIDTH), requiredWidth)); 381 appendMetadataStringsToMessageIfNeeded(locale, metadata, builder); 382 return builder.toString(); 383 case RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_WIDTH_AND_HEIGHT: 384 builder.append( 385 String.format( 386 locale, 387 StringManager.getString( 388 locale, "result_message_customized_small_touch_target_width_and_height"), 389 metadata.getInt(KEY_WIDTH), 390 metadata.getInt(KEY_HEIGHT), 391 metadata.getInt(KEY_CUSTOMIZED_REQUIRED_WIDTH), 392 metadata.getInt(KEY_CUSTOMIZED_REQUIRED_HEIGHT))); 393 appendMetadataStringsToMessageIfNeeded(locale, metadata, builder); 394 return builder.toString(); 395 case RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_HEIGHT: 396 builder.append( 397 String.format( 398 locale, 399 StringManager.getString( 400 locale, "result_message_customized_small_touch_target_height"), 401 metadata.getInt(KEY_HEIGHT), 402 metadata.getInt(KEY_CUSTOMIZED_REQUIRED_HEIGHT))); 403 appendMetadataStringsToMessageIfNeeded(locale, metadata, builder); 404 return builder.toString(); 405 case RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_WIDTH: 406 builder.append( 407 String.format( 408 locale, 409 StringManager.getString( 410 locale, "result_message_customized_small_touch_target_width"), 411 metadata.getInt(KEY_WIDTH), 412 metadata.getInt(KEY_CUSTOMIZED_REQUIRED_WIDTH))); 413 appendMetadataStringsToMessageIfNeeded(locale, metadata, builder); 414 return builder.toString(); 415 default: 416 throw new IllegalStateException("Unsupported result id"); 417 } 418 } 419 420 @Override getShortMessageForResultData( Locale locale, int resultId, @Nullable ResultMetadata metadata)421 public String getShortMessageForResultData( 422 Locale locale, int resultId, @Nullable ResultMetadata metadata) { 423 String generated = generateMessageForResultId(locale, resultId); 424 if (generated != null) { 425 return generated; 426 } 427 428 switch (resultId) { 429 case RESULT_ID_SMALL_TOUCH_TARGET_WIDTH_AND_HEIGHT: 430 case RESULT_ID_SMALL_TOUCH_TARGET_HEIGHT: 431 case RESULT_ID_SMALL_TOUCH_TARGET_WIDTH: 432 case RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_WIDTH_AND_HEIGHT: 433 case RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_WIDTH: 434 case RESULT_ID_CUSTOMIZED_SMALL_TOUCH_TARGET_HEIGHT: 435 return StringManager.getString(locale, "result_message_brief_small_touch_target"); 436 default: 437 throw new IllegalStateException("Unsupported result id"); 438 } 439 } 440 441 /** 442 * Calculates a secondary priority for a touch target result. 443 * 444 * <p>The primary influence on this priority is the minimum touch target dimension in the result. 445 * For example, any result that has a minimum dimension of 2dp (ex. 2dp x 5dp, 45dp x 2dp, or just 446 * 2dp wide) should have a greater priority than any result that has a minimum dimension of 3dp 447 * (ex. 3dp x 3dp, 36dp x 3dp, or just 3dp high). 448 * 449 * <p>The secondary influence on this priority is the maximum touch target dimension in the 450 * result. If a result only has one dimension, the other is regarded as infinite. For example, 451 * among results with a 3dp minimum threshold, 3dp x 3dp would have the highest priority, 3dp x 452 * 5dp (or 5dp x 3dp) would be lower, and just 3dp wide (or just 3dp high) would have the lowest 453 * priority. 454 */ 455 456 @Override getSecondaryPriority(AccessibilityHierarchyCheckResult result)457 public @Nullable Double getSecondaryPriority(AccessibilityHierarchyCheckResult result) { 458 ResultMetadata meta = result.getMetadata(); 459 if (meta == null) { 460 return null; 461 } 462 463 int width = meta.getInt(KEY_WIDTH, Integer.MAX_VALUE); 464 int height = meta.getInt(KEY_HEIGHT, Integer.MAX_VALUE); 465 double primary = Math.min(width, height); 466 if (primary == Integer.MAX_VALUE) { 467 return null; // Neither width nor height is present. 468 } 469 // The divisor of 30 delays the exponential expression from reaching its max value. 470 double secondary = 1.0 / Math.exp(Math.max(width, height) / 30.0d); 471 return -(primary - secondary); 472 } 473 474 @Override getTitleMessage(Locale locale)475 public String getTitleMessage(Locale locale) { 476 return StringManager.getString(locale, "check_title_touch_target_size"); 477 } 478 generateMessageForResultId(Locale locale, int resultId)479 private static @Nullable String generateMessageForResultId(Locale locale, int resultId) { 480 switch (resultId) { 481 case RESULT_ID_NOT_CLICKABLE: 482 return StringManager.getString(locale, "result_message_not_clickable"); 483 case RESULT_ID_NOT_VISIBLE: 484 return StringManager.getString(locale, "result_message_not_visible"); 485 default: 486 return null; 487 } 488 } 489 490 /** 491 * Derives the minimum allowable size for the given {@code view} in dp 492 * 493 * @param view the {@link ViewHierarchyElement} to evaluate 494 * @param parameters Optional check input parameters 495 * @return a {@link Point} representing the minimum allowable size for {@code view} in dp units 496 */ getMinimumAllowableSizeForView( ViewHierarchyElement view, @Nullable Parameters parameters)497 private static Point getMinimumAllowableSizeForView( 498 ViewHierarchyElement view, @Nullable Parameters parameters) { 499 Rect bounds = view.getBoundsInScreen(); 500 Metrics realMetrics = view.getWindow().getAccessibilityHierarchy().getDeviceState() 501 .getDefaultDisplayInfo().getRealMetrics(); 502 503 final int touchTargetMinWidth; 504 final int touchTargetMinHeight; 505 final int touchTargetMinWidthImeContainer; 506 final int touchTargetMinHeightImeContainer; 507 final int touchTargetMinWidthOnEdge; 508 final int touchTargetMinHeightOnEdge; 509 Integer customizedTargetSize = 510 (parameters == null) ? null : parameters.getCustomTouchTargetSize(); 511 if (customizedTargetSize != null) { 512 float targetSize = (float) customizedTargetSize; 513 touchTargetMinWidth = customizedTargetSize; 514 touchTargetMinHeight = customizedTargetSize; 515 touchTargetMinHeightImeContainer = 516 Math.round(TOUCH_TARGET_MIN_HEIGHT_IME_CONTAINER * targetSize / TOUCH_TARGET_MIN_HEIGHT); 517 touchTargetMinWidthImeContainer = 518 Math.round(TOUCH_TARGET_MIN_WIDTH_IME_CONTAINER * targetSize / TOUCH_TARGET_MIN_WIDTH); 519 touchTargetMinHeightOnEdge = 520 Math.round(TOUCH_TARGET_MIN_HEIGHT_ON_EDGE * targetSize / TOUCH_TARGET_MIN_HEIGHT); 521 touchTargetMinWidthOnEdge = 522 Math.round(TOUCH_TARGET_MIN_WIDTH_ON_EDGE * targetSize / TOUCH_TARGET_MIN_WIDTH); 523 } else { 524 touchTargetMinWidth = TOUCH_TARGET_MIN_WIDTH; 525 touchTargetMinHeight = TOUCH_TARGET_MIN_HEIGHT; 526 touchTargetMinHeightImeContainer = TOUCH_TARGET_MIN_HEIGHT_IME_CONTAINER; 527 touchTargetMinWidthImeContainer = TOUCH_TARGET_MIN_WIDTH_IME_CONTAINER; 528 touchTargetMinHeightOnEdge = TOUCH_TARGET_MIN_HEIGHT_ON_EDGE; 529 touchTargetMinWidthOnEdge = TOUCH_TARGET_MIN_WIDTH_ON_EDGE; 530 } 531 532 final int requiredWidth; 533 final int requiredHeight; 534 Integer windowType = view.getWindow().getType(); 535 if ((windowType != null) && (windowType == TYPE_INPUT_METHOD)) { 536 // Contents of input method windows may be smaller 537 requiredWidth = touchTargetMinWidthImeContainer; 538 requiredHeight = touchTargetMinHeightImeContainer; 539 } else if (realMetrics != null) { // JB MR1 and above 540 // Views against the edge of the screen may be smaller in the neighboring dimension 541 boolean viewAgainstSide = 542 (bounds.getLeft() == 0) || (bounds.getRight() == realMetrics.getWidthPixels()); 543 boolean viewAgainstTopOrBottom = 544 (bounds.getTop() == 0) || (bounds.getBottom() == realMetrics.getHeightPixels()); 545 546 requiredWidth = viewAgainstSide ? touchTargetMinWidthOnEdge : touchTargetMinWidth; 547 requiredHeight = viewAgainstTopOrBottom ? touchTargetMinHeightOnEdge : touchTargetMinHeight; 548 } else { 549 // Before JB MR1, we can't get the real size of the screen and thus can't be sure that a 550 // view is against an edge. In that case, we only enforce that the view is above the most 551 // lenient threshold. 552 requiredWidth = Math.min(touchTargetMinWidthOnEdge, touchTargetMinWidth); 553 requiredHeight = Math.min(touchTargetMinHeightOnEdge, touchTargetMinHeight); 554 } 555 556 return new Point(requiredWidth, requiredHeight); 557 } 558 559 /** 560 * Determines if {@code boundingRectInPx} is at least as large in both dimensions as the size 561 * denoted by {@code requiredSizeInDp}. Handles conversion between px and dp based on {@code 562 * density}, rounding the result of such conversion. 563 */ meetsRequiredSize( Rect boundingRectInPx, Point requiredSizeInDp, float density)564 private static boolean meetsRequiredSize( 565 Rect boundingRectInPx, Point requiredSizeInDp, float density) { 566 return (Math.round(boundingRectInPx.getWidth() / density) >= requiredSizeInDp.getX()) 567 && (Math.round(boundingRectInPx.getHeight() / density) >= requiredSizeInDp.getY()); 568 } 569 570 /** 571 * Returns {@code true} if {@code view} has a {@link android.view.TouchDelegate} with hit-Rects of 572 * a known size, {@code false} otherwise 573 */ hasTouchDelegateWithHitRects(ViewHierarchyElement view)574 private static boolean hasTouchDelegateWithHitRects(ViewHierarchyElement view) { 575 return !view.getTouchDelegateBounds().isEmpty(); 576 } 577 578 /** 579 * Determines if any of the {@link android.view.TouchDelegate} hit-Rects delegated to {@code view} 580 * meet the required size represented by {@code requiredSizeInDp} 581 */ hasTouchDelegateOfRequiredSize( ViewHierarchyElement view, Point requiredSizeInDp, float density)582 private static boolean hasTouchDelegateOfRequiredSize( 583 ViewHierarchyElement view, Point requiredSizeInDp, float density) { 584 for (Rect hitRect : view.getTouchDelegateBounds()) { 585 if (meetsRequiredSize(hitRect, requiredSizeInDp, density)) { 586 return true; 587 } 588 } 589 return false; 590 } 591 592 /** 593 * Returns the largest hit-Rect (by area) in screen coordinates (px units) associated with {@code 594 * view}, or {@code null} if no hit-Rects are used 595 */ getLargestTouchDelegateHitRect(ViewHierarchyElement view)596 private static @Nullable Rect getLargestTouchDelegateHitRect(ViewHierarchyElement view) { 597 int largestArea = -1; 598 Rect largestHitRect = null; 599 for (Rect hitRect : view.getTouchDelegateBounds()) { 600 int area = hitRect.getWidth() * hitRect.getHeight(); 601 if (area > largestArea) { 602 largestArea = area; 603 largestHitRect = hitRect; 604 } 605 } 606 return largestHitRect; 607 } 608 609 /** 610 * Determines if any view in the hierarchy above the provided {@code view} has a {@link 611 * android.view.TouchDelegate} set. 612 * 613 * @param view the {@link ViewHierarchyElement} to evaluate 614 * @return {@code true} if an ancestor has a {@link android.view.TouchDelegate} set, {@code false} 615 * if no delegate is set or if this could not be determined. 616 */ hasAncestorWithTouchDelegate(ViewHierarchyElement view)617 private static boolean hasAncestorWithTouchDelegate(ViewHierarchyElement view) { 618 for (ViewHierarchyElement evalView = view.getParentView(); evalView != null; 619 evalView = evalView.getParentView()) { 620 if (TRUE.equals(evalView.hasTouchDelegate())) { 621 return true; 622 } 623 } 624 return false; 625 } 626 627 /** 628 * Determines if any view in the hierarchy above the provided {@code view} matches {@code view}'s 629 * clickability and meets its minimum allowable size. 630 * 631 * @param view the {@link ViewHierarchyElement} to evaluate 632 * @param parameters Optional check input parameters 633 * @return {@code true} if any view in {@code view}'s ancestry that is clickable and/or 634 * long-clickable and meets its minimum allowable size. 635 */ hasQualifyingClickableAncestor( ViewHierarchyElement view, @Nullable Parameters parameters)636 private static boolean hasQualifyingClickableAncestor( 637 ViewHierarchyElement view, @Nullable Parameters parameters) { 638 boolean isTargetClickable = TRUE.equals(view.isClickable()); 639 boolean isTargetLongClickable = TRUE.equals(view.isLongClickable()); 640 ViewHierarchyElement evalView = view.getParentView(); 641 642 while (evalView != null) { 643 if ((TRUE.equals(evalView.isClickable()) && isTargetClickable) 644 || (TRUE.equals(evalView.isLongClickable()) && isTargetLongClickable)) { 645 Point requiredSize = getMinimumAllowableSizeForView(evalView, parameters); 646 Rect bounds = evalView.getBoundsInScreen(); 647 if (!evalView.checkInstanceOf(ABS_LIST_VIEW_CLASS_NAME) 648 && (bounds.getHeight() >= requiredSize.getY()) 649 && (bounds.getWidth() >= requiredSize.getX())) { 650 return true; 651 } 652 } 653 evalView = evalView.getParentView(); 654 } 655 return false; 656 } 657 658 /** 659 * Determines if the provided {@code view} is possibly clipped by one of its ancestor views in 660 * such a way that it may be sufficiently sized if the view were not clipped. 661 * 662 * @param view the {@link ViewHierarchyElement} to evaluate 663 * @param requiredSize a {@link Point} representing the minimum required size of {@code view} 664 * @param density the display density 665 * @return {@code true} if {@code view}'s size is reduced due to the size of one of its ancestor 666 * views, or {@code false} if it is not or this could not be determined. 667 */ hasQualifyingClippingAncestor(ViewHierarchyElement view, Point requiredSize, float density)668 private static boolean hasQualifyingClippingAncestor(ViewHierarchyElement view, 669 Point requiredSize, float density) { 670 Integer rawNonclippedHeight = view.getNonclippedHeight(); 671 Integer rawNonclippedWidth = view.getNonclippedWidth(); 672 if ((rawNonclippedHeight == null) || (rawNonclippedWidth == null)) { 673 return false; 674 } 675 676 Rect clippedBounds = view.getBoundsInScreen(); 677 int clippedHeight = (int) (clippedBounds.getHeight() / density); 678 int clippedWidth = (int) (clippedBounds.getWidth() / density); 679 int nonclippedHeight = (int) (rawNonclippedHeight / density); 680 int nonclippedWidth = (int) (rawNonclippedWidth / density); 681 boolean clippedTooSmallY = clippedHeight < requiredSize.getY(); 682 boolean clippedTooSmallX = clippedWidth < requiredSize.getX(); 683 boolean nonclippedTooSmallY = nonclippedHeight < requiredSize.getY(); 684 boolean nonclippedTooSmallX = nonclippedWidth < requiredSize.getX(); 685 686 return (clippedTooSmallY && !nonclippedTooSmallY) || (clippedTooSmallX && !nonclippedTooSmallX); 687 } 688 689 /** 690 * Identifies web content by checking the ancestors of {@code view} for elements which are WebView 691 * containers. 692 * 693 * @param view the {@link ViewHierarchyElement} to evaluate 694 * @return {@code true} if {@code WebView} was identified as an ancestor, {@code false} otherwise 695 */ 696 private static boolean hasWebViewAncestor(ViewHierarchyElement view) { 697 ViewHierarchyElement parent = view.getParentView(); 698 return (parent != null) 699 && (parent.checkInstanceOf(WEB_VIEW_CLASS_NAME) || hasWebViewAncestor(parent)); 700 } 701 702 /** 703 * Appends result messages for additional metadata fields to the provided {@code builder} if the 704 * relevant keys are set in the given {@code resultMetadata}. 705 * 706 * @param resultMetadata the metadata for the result which should be evaluated 707 * @param builder the {@link StringBuilder} to which result messages should be appended 708 */ 709 private static void appendMetadataStringsToMessageIfNeeded( 710 Locale locale, ResultMetadata resultMetadata, StringBuilder builder) { 711 boolean hasDelegate = resultMetadata.getBoolean(KEY_HAS_TOUCH_DELEGATE, false); 712 boolean hasDelegateWithHitRect = 713 resultMetadata.getBoolean(KEY_HAS_TOUCH_DELEGATE_WITH_HIT_RECT, false); 714 boolean hasClickableAncestor = resultMetadata.getBoolean(KEY_HAS_CLICKABLE_ANCESTOR, false); 715 boolean isClippedByAncestor = resultMetadata.getBoolean(KEY_IS_CLIPPED_BY_ANCESTOR, false); 716 boolean isAgainstScrollableEdge = 717 resultMetadata.getBoolean(KEY_IS_AGAINST_SCROLLABLE_EDGE, false); 718 boolean isWebContent = resultMetadata.getBoolean(KEY_IS_WEB_CONTENT, false); 719 720 if (hasDelegateWithHitRect) { 721 builder 722 .append(' ') 723 .append( 724 String.format( 725 locale, 726 StringManager.getString( 727 locale, "result_message_addendum_touch_delegate_with_hit_rect"), 728 resultMetadata.getInt(KEY_HIT_RECT_WIDTH), 729 resultMetadata.getInt(KEY_HIT_RECT_HEIGHT))); 730 } else if (hasDelegate) { 731 builder.append(' ') 732 .append(StringManager.getString(locale, "result_message_addendum_touch_delegate")); 733 } 734 if (isWebContent) { 735 builder.append(' ') 736 .append(StringManager.getString(locale, "result_message_addendum_web_touch_target_size")); 737 } else if (hasClickableAncestor) { 738 // The Web content addendum should supersede more-generic ancestor clickability information 739 builder 740 .append(' ') 741 .append(StringManager.getString(locale, "result_message_addendum_clickable_ancestor")); 742 } 743 if (isClippedByAncestor) { 744 builder.append(' ').append(String.format(locale, 745 StringManager.getString(locale, "result_message_addendum_clipped_by_ancestor"), 746 resultMetadata.getInt(KEY_NONCLIPPED_WIDTH), 747 resultMetadata.getInt(KEY_NONCLIPPED_HEIGHT))); 748 } 749 if (isAgainstScrollableEdge) { 750 builder 751 .append(' ') 752 .append( 753 StringManager.getString(locale, "result_message_addendum_against_scrollable_edge")); 754 } 755 } 756 } 757