• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 package com.google.android.apps.common.testing.accessibility.framework.uielement;
2 
3 import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_RENDERING_INFO_KEY;
4 import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH;
5 import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX;
6 import static android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY;
7 import static java.lang.Math.ceil;
8 import static java.lang.Math.floor;
9 import static java.lang.Math.min;
10 
11 import android.graphics.RectF;
12 import android.os.Build;
13 import android.os.Build.VERSION;
14 import android.os.Build.VERSION_CODES;
15 import android.os.Bundle;
16 import android.os.Parcelable;
17 import android.util.Size;
18 import android.view.accessibility.AccessibilityNodeInfo;
19 import android.widget.TextView;
20 import androidx.annotation.RequiresApi;
21 import com.google.android.apps.common.testing.accessibility.framework.replacements.Rect;
22 import com.google.android.apps.common.testing.accessibility.framework.replacements.TextUtils;
23 import com.google.common.annotations.VisibleForTesting;
24 import com.google.common.collect.ImmutableList;
25 import com.google.errorprone.annotations.CanIgnoreReturnValue;
26 import org.checkerframework.checker.nullness.qual.Nullable;
27 
28 /** Extracts extra data from the View associated with an {@link AccessibilityNodeInfo}. */
29 class AccessibilityNodeInfoExtraDataExtractor {
30 
31   /**
32    * See
33    * https://developer.android.com/reference/kotlin/androidx/compose/ui/semantics/package-summary#(androidx.compose.ui.semantics.SemanticsPropertyReceiver).testTag()
34    */
35   static final String EXTRA_DATA_TEST_TAG = "androidx.compose.ui.semantics.testTag";
36 
37   private final boolean obtainCharacterLocations;
38   @VisibleForTesting final boolean obtainRenderingInfo;
39 
40   /** The maximum allowed length of the requested text location data. */
41   private final @Nullable Integer characterLocationArgMaxLength;
42 
43   /**
44    * @param obtainCharacterLocations whether character locations are desired
45    * @param obtainRenderingInfo whether rendering info is desired
46    * @param characterLocationArgMaxLength The maximum allowed length of the requested text location
47    *     data
48    */
AccessibilityNodeInfoExtraDataExtractor( boolean obtainCharacterLocations, boolean obtainRenderingInfo, @Nullable Integer characterLocationArgMaxLength)49   AccessibilityNodeInfoExtraDataExtractor(
50       boolean obtainCharacterLocations,
51       boolean obtainRenderingInfo,
52       @Nullable Integer characterLocationArgMaxLength) {
53     this.obtainCharacterLocations = obtainCharacterLocations;
54     this.obtainRenderingInfo = obtainRenderingInfo;
55     this.characterLocationArgMaxLength = characterLocationArgMaxLength;
56   }
57 
getExtraData(AccessibilityNodeInfo fromInfo)58   ExtraData getExtraData(AccessibilityNodeInfo fromInfo) {
59     ExtraData extraData = new ExtraData();
60 
61     if (obtainCharacterLocations && (VERSION.SDK_INT >= VERSION_CODES.O)) {
62       fetchTextCharacterLocations(fromInfo, extraData, characterLocationArgMaxLength);
63     }
64 
65     if (obtainRenderingInfo && (VERSION.SDK_INT >= VERSION_CODES.R)) {
66       fetchRenderingInfo(fromInfo, extraData);
67     }
68 
69     if (VERSION.SDK_INT >= VERSION_CODES.O) {
70       fetchTestTag(fromInfo, extraData);
71     }
72 
73     return extraData;
74   }
75 
76   /**
77    * Retrieves text character locations for the {@code TextView}'s text.
78    *
79    * <p>Returns an empty list if the text is {@code null} or an empty string, or if the character
80    * locations are not available. Limits the size of the result if the constructor was given a
81    * non-null value for characterLocationArgMaxLength.
82    *
83    * @param textView A {@code TextView} which may have a text
84    * @return The locations of each text character in screen coordinates
85    */
getTextCharacterLocations(TextView textView)86   ImmutableList<Rect> getTextCharacterLocations(TextView textView) {
87     return getTextCharacterLocations(textView, characterLocationArgMaxLength);
88   }
89 
90   @VisibleForTesting
91   @RequiresApi(VERSION_CODES.O)
getTextCharacterLocations( TextView textView, @Nullable Integer characterLocationArgMaxLength)92   ImmutableList<Rect> getTextCharacterLocations(
93       TextView textView, @Nullable Integer characterLocationArgMaxLength) {
94     return (obtainCharacterLocations && (VERSION.SDK_INT >= VERSION_CODES.O))
95         ? getTextCharacterLocationsAux(textView, characterLocationArgMaxLength)
96         : ImmutableList.of();
97   }
98 
99   @RequiresApi(VERSION_CODES.O)
getTextCharacterLocationsAux( TextView textView, @Nullable Integer maxCharacterLocationLength)100   private static ImmutableList<Rect> getTextCharacterLocationsAux(
101       TextView textView, @Nullable Integer maxCharacterLocationLength) {
102     CharSequence text = textView.getText();
103     if (TextUtils.isEmpty(text) || (textView.getLayout() == null)) {
104       return ImmutableList.of();
105     }
106 
107     AccessibilityNodeInfo nodeInfo = AccessibilityNodeInfo.obtain();
108     Bundle args = createTextCharacterLocationsRequestBundle(text, maxCharacterLocationLength);
109     textView.addExtraDataToAccessibilityNodeInfo(
110         nodeInfo, EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, args);
111     ImmutableList<Rect> locations = parseCharacterLocationsFromExtras(nodeInfo.getExtras());
112     return (locations == null) ? ImmutableList.of() : locations;
113   }
114 
115   /**
116    * Retrieves the rendering info (text size, text size unit and layout size) and stores it in
117    * {@code extraData}.
118    *
119    * <p>The results will be {@code null} if the info is not available.
120    *
121    * @param fromInfo The node of interest
122    * @param extraData destination for rendering info
123    */
124   @RequiresApi(VERSION_CODES.R)
fetchRenderingInfo(AccessibilityNodeInfo fromInfo, ExtraData extraData)125   private static void fetchRenderingInfo(AccessibilityNodeInfo fromInfo, ExtraData extraData) {
126     if (safeRefreshWithExtraData(fromInfo, EXTRA_DATA_RENDERING_INFO_KEY, new Bundle())) {
127         AccessibilityNodeInfo.ExtraRenderingInfo extraRenderingInfo =
128             fromInfo.getExtraRenderingInfo();
129         if (extraRenderingInfo != null) {
130           float size = extraRenderingInfo.getTextSizeInPx();
131           if (size >= 0) {
132             extraData.setTextSize(size);
133           }
134           int textSizeUnit = extraRenderingInfo.getTextSizeUnit();
135           if (textSizeUnit >= 0) {
136             extraData.setTextSizeUnit(textSizeUnit);
137           }
138           Size layoutSize = extraRenderingInfo.getLayoutSize();
139           if (layoutSize != null) {
140             extraData.setLayoutSize(layoutSize);
141           }
142         }
143     }
144   }
145 
146   /**
147    * Retrieves the locations of each text character in screen coordinates and stores them in {@code
148    * extraData}.
149    *
150    * <p>The result will be an empty list if the text is {@code null} or an empty string, and will be
151    * {@code null} if the character locations are not available.
152    *
153    * @param fromInfo The {@link AccessibilityNodeInfo} which may have a text
154    * @param extraData destination for locations
155    */
156   @RequiresApi(VERSION_CODES.O)
fetchTextCharacterLocations( AccessibilityNodeInfo fromInfo, ExtraData extraData, @Nullable Integer maxCharacterLocationLength)157   private static void fetchTextCharacterLocations(
158       AccessibilityNodeInfo fromInfo,
159       ExtraData extraData,
160       @Nullable Integer maxCharacterLocationLength) {
161     CharSequence text = fromInfo.getText();
162     if (TextUtils.isEmpty(text)) {
163       extraData.setTextCharacterLocations(ImmutableList.of());
164     } else {
165       Bundle args = createTextCharacterLocationsRequestBundle(text, maxCharacterLocationLength);
166       if (safeRefreshWithExtraData(fromInfo, EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY, args)) {
167         ImmutableList<Rect> locations = parseCharacterLocationsFromExtras(fromInfo.getExtras());
168         if (locations != null) {
169           extraData.setTextCharacterLocations(locations);
170         }
171       }
172     }
173   }
174 
175   @RequiresApi(VERSION_CODES.O)
parseCharacterLocationsFromExtras(Bundle extras)176   private static @Nullable ImmutableList<Rect> parseCharacterLocationsFromExtras(Bundle extras) {
177     Parcelable[] data = extras.getParcelableArray(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY);
178     if (data == null) {
179       return null;
180     }
181 
182     ImmutableList.Builder<Rect> charLocations = ImmutableList.builder();
183     for (Parcelable item : data) {
184       if (item instanceof RectF) {
185         // Rounds "out" the rectangle by choosing the floor of top and left, and the ceiling of
186         // right and bottom.
187         RectF rectF = (RectF) item;
188         charLocations.add(
189             new Rect(
190                 (int) floor(rectF.left),
191                 (int) floor(rectF.top),
192                 (int) ceil(rectF.right),
193                 (int) ceil(rectF.bottom)));
194       }
195     }
196     return charLocations.build();
197   }
198 
199   @RequiresApi(VERSION_CODES.O)
createTextCharacterLocationsRequestBundle( CharSequence text, @Nullable Integer maxCharacterLocationLength)200   private static Bundle createTextCharacterLocationsRequestBundle(
201       CharSequence text, @Nullable Integer maxCharacterLocationLength) {
202     Bundle args = new Bundle();
203     args.putInt(EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, 0);
204     args.putInt(
205         EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH,
206         (maxCharacterLocationLength == null)
207             ? text.length()
208             : min(maxCharacterLocationLength, text.length()));
209     return args;
210   }
211 
212   /**
213    * Retrieves the Compose testTag and stores it in {@code extraData}.
214    *
215    * <p>The result will be {@code null} if a testTag has not been set.
216    *
217    * @param fromInfo The {@link AccessibilityNodeInfo} which may have a test tag
218    * @param extraData destination for the testTag
219    */
220   @RequiresApi(VERSION_CODES.O)
fetchTestTag(AccessibilityNodeInfo fromInfo, ExtraData extraData)221   private static void fetchTestTag(AccessibilityNodeInfo fromInfo, ExtraData extraData) {
222     if (fromInfo.getAvailableExtraData().contains(EXTRA_DATA_TEST_TAG)
223         && safeRefreshWithExtraData(fromInfo, EXTRA_DATA_TEST_TAG, new Bundle())) {
224         CharSequence testTag = fromInfo.getExtras().getCharSequence(EXTRA_DATA_TEST_TAG);
225         if (testTag != null) {
226           extraData.setTestTag(testTag);
227         }
228       }
229   }
230 
231   /**
232    * Calls AccessibilityNodeInfo#refreshWithExtraData(String, Bundle), but handles any thrown
233    * IllegalStateException if this code is being run in a Robolectric test. refreshWithExtraData
234    * throws an IllegalStateException when it is called on a sealed instance.
235    *
236    * @return {@code true} iff the refresh succeeded.
237    */
safeRefreshWithExtraData( AccessibilityNodeInfo fromInfo, String extraDataKey, Bundle args)238   private static boolean safeRefreshWithExtraData(
239       AccessibilityNodeInfo fromInfo, String extraDataKey, Bundle args) {
240     try {
241       return fromInfo.refreshWithExtraData(extraDataKey, args);
242     } catch (IllegalStateException e) {
243       if (isRobolectric()) {
244         return false;
245       }
246       throw e;
247     }
248   }
249 
isRobolectric()250   private static boolean isRobolectric() {
251     return "robolectric".equals(Build.FINGERPRINT);
252   }
253 
254   static class ExtraData {
255     private @Nullable Float textSize = null;
256     private @Nullable Integer textSizeUnit = null;
257     private @Nullable Size layoutSize = null;
258     private @Nullable ImmutableList<Rect> textCharacterLocations = null;
259     private @Nullable CharSequence testTag = null;
260 
ExtraData()261     ExtraData() {}
262 
263     /**
264      * Gets the text size if the node is a {@code TextView}.
265      *
266      * <p>Returns {@code null} if {@code obtainRenderingInfo} was {@code false}, or if the text size
267      * is not available.
268      *
269      * @return the text size in px
270      */
getTextSize()271     @Nullable Float getTextSize() {
272       return textSize;
273     }
274 
275     @CanIgnoreReturnValue
276     @VisibleForTesting
setTextSize(Float textSize)277     ExtraData setTextSize(Float textSize) {
278       this.textSize = textSize;
279       return this;
280     }
281 
282     /**
283      * Gets the text size unit defined by the developer if {@code obtainRenderingInfo} is {@code
284      * true}.
285      *
286      * <p>Returns {@code null} if {@code obtainRenderingInfo} was {@code false}, or if the text size
287      * unit is not available.
288      *
289      * @return the dimension type of the text size unit originally defined.
290      * @see android.util.TypedValue#TYPE_DIMENSION
291      */
getTextSizeUnit()292     @Nullable Integer getTextSizeUnit() {
293       return textSizeUnit;
294     }
295 
296     @CanIgnoreReturnValue
297     @VisibleForTesting
setTextSizeUnit(Integer textSizeUnit)298     ExtraData setTextSizeUnit(Integer textSizeUnit) {
299       this.textSizeUnit = textSizeUnit;
300       return this;
301     }
302 
303     /**
304      * Gets the size object containing the height and the width of {@link
305      * android.view.ViewGroup.LayoutParams} if the node is a {@link ViewGroup} or a {@link
306      * TextView}, or null otherwise.
307      *
308      * <p>Returns {@code null} if {@code obtainRenderingInfo} was {@code false}, or if the text size
309      * unit is not available.
310      *
311      * @see android.view.accessibility.AccessibilityNodeInfo.ExtraRenderingInfo#getLayoutSize
312      */
getLayoutSize()313     @Nullable Size getLayoutSize() {
314       return layoutSize;
315     }
316 
317     @CanIgnoreReturnValue
318     @VisibleForTesting
setLayoutSize(Size layoutSize)319     ExtraData setLayoutSize(Size layoutSize) {
320       this.layoutSize = layoutSize;
321       return this;
322     }
323 
324     /**
325      * Retrieves text character locations if {@code obtainCharacterLocations} is {@code true}.
326      *
327      * <p>Returns an empty list if the text is {@code null} or an empty string. Returns {@code null}
328      * if {@code obtainCharacterLocations} was {@code false}, or if the character locations are not
329      * available.
330      *
331      * @return The locations of each text character in screen coordinates
332      */
getTextCharacterLocations()333     @Nullable ImmutableList<Rect> getTextCharacterLocations() {
334       return textCharacterLocations;
335     }
336 
337     @CanIgnoreReturnValue
338     @VisibleForTesting
setTextCharacterLocations(ImmutableList<Rect> textCharacterLocations)339     ExtraData setTextCharacterLocations(ImmutableList<Rect> textCharacterLocations) {
340       this.textCharacterLocations = textCharacterLocations;
341       return this;
342     }
343 
344     /**
345      * Gets the Compose testTag if one has been set.
346      *
347      * @return The Compose testTag or {@code null} if one has not been set
348      */
getTestTag()349     @Nullable CharSequence getTestTag() {
350       return testTag;
351     }
352 
353     @CanIgnoreReturnValue
354     @VisibleForTesting
setTestTag(CharSequence testTag)355     ExtraData setTestTag(CharSequence testTag) {
356       this.testTag = testTag;
357       return this;
358     }
359   }
360 }
361