• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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