1 /* 2 * Copyright (C) 2020 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.tools.idea.validator; 18 19 import com.android.tools.idea.validator.ValidatorData.CompoundFix; 20 import com.android.tools.idea.validator.ValidatorData.Issue; 21 import com.android.tools.idea.validator.ValidatorData.Issue.IssueBuilder; 22 import com.android.tools.idea.validator.ValidatorData.Level; 23 import com.android.tools.idea.validator.ValidatorData.RemoveViewAttributeFix; 24 import com.android.tools.idea.validator.ValidatorData.SetViewAttributeFix; 25 import com.android.tools.idea.validator.ValidatorData.Type; 26 import com.android.tools.idea.validator.ValidatorResult.Builder; 27 import com.android.tools.idea.validator.hierarchy.CustomHierarchyHelper; 28 import com.android.tools.layoutlib.annotations.NotNull; 29 import com.android.tools.layoutlib.annotations.Nullable; 30 31 import android.view.View; 32 33 import java.awt.image.BufferedImage; 34 import java.io.PrintWriter; 35 import java.io.StringWriter; 36 import java.util.ArrayList; 37 import java.util.EnumSet; 38 import java.util.HashSet; 39 import java.util.List; 40 import java.util.Locale; 41 import java.util.ResourceBundle; 42 import java.util.Set; 43 import java.util.stream.Collectors; 44 45 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheck; 46 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckPreset; 47 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType; 48 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheck; 49 import com.google.android.apps.common.testing.accessibility.framework.AccessibilityHierarchyCheckResult; 50 import com.google.android.apps.common.testing.accessibility.framework.Parameters; 51 import com.google.android.apps.common.testing.accessibility.framework.checks.EditableContentDescCheck; 52 import com.google.android.apps.common.testing.accessibility.framework.checks.SpeakableTextPresentCheck; 53 import com.google.android.apps.common.testing.accessibility.framework.checks.TextContrastCheck; 54 import com.google.android.apps.common.testing.accessibility.framework.checks.TouchTargetSizeCheck; 55 import com.google.android.apps.common.testing.accessibility.framework.strings.StringManager; 56 import com.google.android.apps.common.testing.accessibility.framework.suggestions.CompoundFixSuggestions; 57 import com.google.android.apps.common.testing.accessibility.framework.suggestions.FixSuggestion; 58 import com.google.android.apps.common.testing.accessibility.framework.suggestions.FixSuggestionPreset; 59 import com.google.android.apps.common.testing.accessibility.framework.suggestions.RemoveViewAttributeFixSuggestion; 60 import com.google.android.apps.common.testing.accessibility.framework.suggestions.SetViewAttributeFixSuggestion; 61 import com.google.android.apps.common.testing.accessibility.framework.suggestions.ViewAttribute; 62 import com.google.android.apps.common.testing.accessibility.framework.uielement.AccessibilityHierarchy; 63 import com.google.android.apps.common.testing.accessibility.framework.uielement.AccessibilityHierarchyAndroid; 64 import com.google.android.apps.common.testing.accessibility.framework.uielement.CustomViewBuilderAndroid; 65 import com.google.android.apps.common.testing.accessibility.framework.uielement.DefaultCustomViewBuilderAndroid; 66 import com.google.android.apps.common.testing.accessibility.framework.uielement.ViewHierarchyElementAndroid; 67 import com.google.common.collect.ImmutableList; 68 import com.google.common.collect.ImmutableSet; 69 70 public class ValidatorUtil { 71 72 static { 73 /** 74 * Overriding default ResourceBundle ATF uses. ATF would use generic Java resources 75 * instead of Android's .xml. 76 * 77 * By default ATF generates ResourceBundle to support Android specific env/ classloader, 78 * which is quite different from Layoutlib, which supports multiple classloader depending 79 * on env (testing vs in studio). 80 * 81 * To support ATF in Layoutlib, easiest way is to convert resources from Android xml to 82 * generic Java resources (strings.properties), and have the default ResourceBundle ATF 83 * uses be redirected. 84 */ 85 StringManager.setResourceBundleProvider(locale -> ResourceBundle.getBundle("strings")); 86 // Enable using AccessibilityNodeInfo in addition to View for accessibility testing 87 AccessibilityHierarchyAndroid.viewOverlayEnabled = true; 88 } 89 90 // Visible for testing. 91 protected static DefaultCustomViewBuilderAndroid sDefaultCustomViewBuilderAndroid = 92 new DefaultCustomViewBuilderAndroid(); 93 94 /** 95 * Fixes could be only provided for a {@link AccessibilityHierarchyCheckResult}s generated by 96 * a predefined set of {@link AccessibilityHierarchyCheck}s. 97 */ 98 private final static ImmutableSet<Class<? extends AccessibilityHierarchyCheck>> 99 sAllowedCheckResultClassSet4Fix = ImmutableSet.of(SpeakableTextPresentCheck.class, 100 TextContrastCheck.class, TouchTargetSizeCheck.class, EditableContentDescCheck.class); 101 102 /** 103 * The maximum allowed length of the requested text location data is used to avoid the 104 * performance issue caused by obtaining character location data for a view with a long text. 105 */ 106 public static final int CHARACTER_LOCATION_ARG_MAX_LENGTH = 100; 107 108 /** 109 * @param policy policy to apply for the hierarchy 110 * @param view root view to build hierarchy from 111 * @param image screenshot image that matches the view 112 * @param scaleX scaling done via layoutlib in x coord 113 * @param scaleY scaling done via layoutlib in y coord 114 * @return The hierarchical data required for running the ATF checks. 115 */ buildHierarchy( @otNull ValidatorData.Policy policy, @NotNull View view, @Nullable BufferedImage image, float scaleX, float scaleY)116 public static ValidatorHierarchy buildHierarchy( 117 @NotNull ValidatorData.Policy policy, 118 @NotNull View view, 119 @Nullable BufferedImage image, 120 float scaleX, 121 float scaleY) { 122 ValidatorHierarchy hierarchy = new ValidatorHierarchy(); 123 if (!policy.mTypes.contains(Type.ACCESSIBILITY)) { 124 return hierarchy; 125 } 126 127 ValidatorResult.Builder builder = new ValidatorResult.Builder(); 128 @Nullable Parameters parameters = null; 129 builder.mMetric.startHierarchyCreationTimer(); 130 try { 131 hierarchy.mView = AccessibilityHierarchyAndroid 132 .newBuilder(view) 133 .setViewOriginMap(builder.mSrcMap) 134 .setObtainCharacterLocations(LayoutValidator.obtainCharacterLocations()) 135 .setCharacterLocationArgMaxLength(CHARACTER_LOCATION_ARG_MAX_LENGTH) 136 .setCustomViewBuilder(new CustomViewBuilderAndroid() { 137 @Override 138 public Class<?> getClassByName( 139 ViewHierarchyElementAndroid viewHierarchyElementAndroid, 140 String className) { 141 Class<?> toReturn = sDefaultCustomViewBuilderAndroid.getClassByName( 142 viewHierarchyElementAndroid, className); 143 if (toReturn == null) { 144 toReturn = CustomHierarchyHelper.getClassByName(className); 145 } 146 return toReturn; 147 } 148 149 @Override 150 public boolean isCheckable(View view) { 151 return CustomHierarchyHelper.isCheckable(view); 152 } 153 }).build(); 154 if (image != null) { 155 parameters = new Parameters(); 156 parameters.putScreenCapture( 157 new AtfBufferedImage(image, builder.mMetric, scaleX, scaleY)); 158 } 159 } finally { 160 builder.mMetric.recordHierarchyCreationTime(); 161 } 162 163 hierarchy.mBuilder = builder; 164 hierarchy.mParameters = parameters; 165 return hierarchy; 166 } 167 168 /** 169 * @param hierarchy to build result from. If {@link ValidatorHierarchy#isHierarchyBuilt()} 170 * is false, returns a result with an internal error. 171 * @return Returns ValidatorResult with given hierarchical data. 172 */ generateResults( @otNull ValidatorData.Policy policy, @NotNull ValidatorHierarchy hierarchy)173 public static ValidatorResult generateResults( 174 @NotNull ValidatorData.Policy policy, 175 @NotNull ValidatorHierarchy hierarchy) { 176 ValidatorResult.Builder builder = hierarchy.mBuilder; 177 try { 178 if (!hierarchy.isHierarchyBuilt()) { 179 // Unable to build. 180 builder = new Builder(); 181 String errorMsg = hierarchy.mErrorMessage != null ? hierarchy.mErrorMessage : 182 "Hierarchy is not built yet."; 183 builder.mIssues.add(new IssueBuilder() 184 .setCategory("Accessibility") 185 .setType(Type.INTERNAL_ERROR) 186 .setMsg(errorMsg) 187 .setLevel(Level.ERROR) 188 .setSourceClass("ValidatorHierarchy") 189 .build()); 190 return builder.build(); 191 } 192 builder.mMetric.startGenerateResultsTimer(); 193 194 AccessibilityHierarchyAndroid view = hierarchy.mView; 195 Parameters parameters = hierarchy.mParameters; 196 197 EnumSet<Level> filter = policy.mLevels; 198 ArrayList<AccessibilityHierarchyCheckResult> a11yResults = new ArrayList<>(); 199 200 HashSet<AccessibilityHierarchyCheck> policyChecks = policy.mChecks; 201 @NotNull Set<AccessibilityHierarchyCheck> checks = policyChecks.isEmpty() ? 202 AccessibilityCheckPreset.getAccessibilityHierarchyChecksForPreset( 203 AccessibilityCheckPreset.LATEST) : policyChecks; 204 205 for (AccessibilityHierarchyCheck check : checks) { 206 a11yResults.addAll(check.runCheckOnHierarchy(view, null, parameters)); 207 } 208 209 for (AccessibilityHierarchyCheckResult result : a11yResults) { 210 // TODO: b/183726816 replace this with 211 // AccessibilityCheckPreset.getHierarchyCheckForClassName(checkClassName) 212 // .getTitleMessage(Locale.ENGLISH) 213 String category = ValidatorUtil.getCheckClassCategory(result.getSourceCheckClass()); 214 215 ValidatorData.Level level = ValidatorUtil.convertLevel(result.getType()); 216 if (!filter.contains(level)) { 217 continue; 218 } 219 220 try { 221 IssueBuilder issueBuilder = new IssueBuilder().setCategory(category).setMsg( 222 result.getMessage(Locale.ENGLISH).toString()).setLevel(level).setFix( 223 ValidatorUtil.generateFix(result, view, parameters)).setSourceClass( 224 result.getSourceCheckClass().getSimpleName()); 225 if (result.getElement() != null) { 226 issueBuilder.setSrcId(result.getElement().getCondensedUniqueId()); 227 } 228 AccessibilityHierarchyCheck subclass = 229 AccessibilityCheckPreset.getHierarchyCheckForClass( 230 result.getSourceCheckClass().asSubclass( 231 AccessibilityHierarchyCheck.class)); 232 if (subclass != null) { 233 issueBuilder.setHelpfulUrl(subclass.getHelpUrl()); 234 } 235 builder.mIssues.add(issueBuilder.build()); 236 } catch (Exception e) { 237 StringWriter sw = new StringWriter(); 238 PrintWriter pw = new PrintWriter(sw); 239 e.printStackTrace(pw); 240 builder.mIssues.add(new IssueBuilder() 241 .setCategory(category) 242 .setType(Type.INTERNAL_ERROR) 243 .setMsg(sw.toString()) 244 .setLevel(Level.ERROR) 245 .setSourceClass("ValidatorHierarchy").build()); 246 } 247 } 248 } finally { 249 builder.mMetric.recordGenerateResultsTime(); 250 } 251 return builder.build(); 252 } 253 254 /** 255 * @return the list of internal errors in results. Useful for testing and debugging. 256 */ filterInternalErrors(List<ValidatorData.Issue> results)257 public static List<Issue> filterInternalErrors(List<ValidatorData.Issue> results) { 258 return filterByTypes(results, EnumSet.of(Type.INTERNAL_ERROR)); 259 } 260 261 /** 262 * @return the list filtered by the level. Useful for testing and debugging. 263 */ filter(List<ValidatorData.Issue> results, EnumSet<Level> errors)264 public static List<Issue> filter(List<ValidatorData.Issue> results, EnumSet<Level> errors) { 265 return results.stream().filter( 266 issue -> errors.contains(issue.mLevel)).collect(Collectors.toList()); 267 } 268 269 /** 270 * @return the list filtered by the source class name. Useful for testing and debugging. 271 */ filter( List<ValidatorData.Issue> results, String sourceClass)272 public static List<Issue> filter( 273 List<ValidatorData.Issue> results, String sourceClass) { 274 return results.stream().filter( 275 issue -> sourceClass.equals(issue.mSourceClass)).collect(Collectors.toList()); 276 } 277 278 /** 279 * @return the list filtered by the source class name. Useful for testing and debugging. 280 */ filterByTypes( List<ValidatorData.Issue> results, EnumSet<Type> types)281 public static List<Issue> filterByTypes( 282 List<ValidatorData.Issue> results, EnumSet<Type> types) { 283 return results.stream().filter( 284 issue -> types.contains(issue.mType)).collect(Collectors.toList()); 285 } 286 287 /** 288 * @param checkClass classes expected to extend AccessibilityHierarchyCheck 289 * @return {@link AccessibilityCheck.Category} of the class. 290 */ 291 @NotNull getCheckClassCategory(@otNull Class<?> checkClass)292 private static String getCheckClassCategory(@NotNull Class<?> checkClass) { 293 try { 294 Class<? extends AccessibilityHierarchyCheck> subClass = 295 checkClass.asSubclass(AccessibilityHierarchyCheck.class); 296 AccessibilityHierarchyCheck check = 297 AccessibilityCheckPreset.getHierarchyCheckForClass(subClass); 298 return (check == null) ? "Accessibility" : check.getCategory().name(); 299 } catch (ClassCastException e) { 300 return "Accessibility"; 301 } 302 } 303 304 /** Convert {@link AccessibilityCheckResultType} to {@link ValidatorData.Level} */ 305 @NotNull convertLevel(@otNull AccessibilityCheckResultType type)306 private static ValidatorData.Level convertLevel(@NotNull AccessibilityCheckResultType type) { 307 switch (type) { 308 case ERROR: 309 return Level.ERROR; 310 case WARNING: 311 return Level.WARNING; 312 case INFO: 313 return Level.INFO; 314 // TODO: Maybe useful later? 315 case SUPPRESSED: 316 case NOT_RUN: 317 default: 318 return Level.VERBOSE; 319 } 320 } 321 322 /** 323 * Create a {@link ValidatorData.Fix} for the given result, or {@code null} if there is no 324 * fixes available. 325 * 326 * <p>If there are multiple fixes available, return the first fix which is considered to be the 327 * best fix available. 328 * 329 * @param result to generate a fix from. 330 * @param hierarchy The hierarchy from which the result is generated from. 331 * @param parameters Optional input data or preferences. 332 */ 333 @Nullable generateFix( @otNull AccessibilityHierarchyCheckResult result, @NotNull AccessibilityHierarchy hierarchy, @Nullable Parameters parameters)334 private static ValidatorData.Fix generateFix( 335 @NotNull AccessibilityHierarchyCheckResult result, 336 @NotNull AccessibilityHierarchy hierarchy, 337 @Nullable Parameters parameters) { 338 if (sAllowedCheckResultClassSet4Fix.contains(result.getSourceCheckClass())) { 339 ImmutableList<FixSuggestion> fixSuggestions = 340 FixSuggestionPreset.provideFixSuggestions(result, hierarchy, parameters); 341 return fixSuggestions.isEmpty() ? null : convertFix(fixSuggestions.get(0)); 342 } 343 return null; 344 } 345 346 /** Convert {@link FixSuggestion} to {@link ValidatorData.Fix} */ 347 @Nullable convertFix(@otNull FixSuggestion fixSuggestion)348 private static ValidatorData.Fix convertFix(@NotNull FixSuggestion fixSuggestion) { 349 if (fixSuggestion instanceof CompoundFixSuggestions) { 350 CompoundFixSuggestions compoundFixSuggestions = (CompoundFixSuggestions)fixSuggestion; 351 List<ValidatorData.Fix> fixes = 352 compoundFixSuggestions 353 .getFixSuggestions() 354 .stream() 355 .map(ValidatorUtil::convertFix) 356 .collect(Collectors.toList()); 357 return new CompoundFix( 358 fixes, 359 compoundFixSuggestions.getDescription(Locale.ENGLISH)); 360 } else if (fixSuggestion instanceof RemoveViewAttributeFixSuggestion) { 361 RemoveViewAttributeFixSuggestion removeViewAttributeFix = 362 (RemoveViewAttributeFixSuggestion)fixSuggestion; 363 return new RemoveViewAttributeFix( 364 convertViewAttribute(removeViewAttributeFix.getViewAttribute()), 365 removeViewAttributeFix.getDescription(Locale.ENGLISH)); 366 } else if (fixSuggestion instanceof SetViewAttributeFixSuggestion) { 367 SetViewAttributeFixSuggestion setViewAttributeFixSuggestion = 368 (SetViewAttributeFixSuggestion)fixSuggestion; 369 return new SetViewAttributeFix( 370 convertViewAttribute(setViewAttributeFixSuggestion.getViewAttribute()), 371 setViewAttributeFixSuggestion.getSuggestedValue(), 372 setViewAttributeFixSuggestion.getDescription(Locale.ENGLISH)); 373 } 374 return null; 375 } 376 377 /** Convert {@link ViewAttribute} to {@link ValidatorData.ViewAttribute} */ 378 @NotNull convertViewAttribute( @otNull ViewAttribute viewAttribute)379 private static ValidatorData.ViewAttribute convertViewAttribute( 380 @NotNull ViewAttribute viewAttribute) { 381 return new ValidatorData.ViewAttribute( 382 viewAttribute.getNamespaceUri(), 383 viewAttribute.getNamespace(), 384 viewAttribute.getAttributeName()); 385 } 386 } 387