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