1 package com.google.android.apps.common.testing.accessibility.framework; 2 3 import static com.google.common.base.Preconditions.checkNotNull; 4 import static java.lang.Boolean.FALSE; 5 import static java.lang.Boolean.TRUE; 6 7 import com.google.android.apps.common.testing.accessibility.framework.replacements.Rect; 8 import com.google.android.apps.common.testing.accessibility.framework.replacements.SpannableString; 9 import com.google.android.apps.common.testing.accessibility.framework.replacements.SpannableStringBuilder; 10 import com.google.android.apps.common.testing.accessibility.framework.replacements.TextUtils; 11 import com.google.android.apps.common.testing.accessibility.framework.strings.StringManager; 12 import com.google.android.apps.common.testing.accessibility.framework.uielement.AccessibilityHierarchy; 13 import com.google.android.apps.common.testing.accessibility.framework.uielement.ViewHierarchyElement; 14 import com.google.android.apps.common.testing.accessibility.framework.uielement.WindowHierarchyElement; 15 import com.google.common.base.Ascii; 16 import com.google.common.collect.ImmutableList; 17 import java.util.HashSet; 18 import java.util.Locale; 19 import org.checkerframework.checker.nullness.qual.Nullable; 20 21 /** 22 * Utility class for initialization and evaluation of ViewHierarchyElements 23 */ 24 public final class ViewHierarchyElementUtils { 25 public static final String ABS_LIST_VIEW_CLASS_NAME = "android.widget.AbsListView"; 26 public static final String ADAPTER_VIEW_CLASS_NAME = "android.widget.AdapterView"; 27 public static final String SCROLL_VIEW_CLASS_NAME = "android.widget.ScrollView"; 28 public static final String HORIZONTAL_SCROLL_VIEW_CLASS_NAME = 29 "android.widget.HorizontalScrollView"; 30 public static final String SPINNER_CLASS_NAME = "android.widget.Spinner"; 31 public static final String TEXT_VIEW_CLASS_NAME = "android.widget.TextView"; 32 public static final String EDIT_TEXT_CLASS_NAME = "android.widget.EditText"; 33 public static final String IMAGE_VIEW_CLASS_NAME = "android.widget.ImageView"; 34 public static final String WEB_VIEW_CLASS_NAME = "android.webkit.WebView"; 35 public static final String SWITCH_CLASS_NAME = "android.widget.Switch"; 36 public static final String TOGGLE_BUTTON_CLASS_NAME = "android.widget.ToggleButton"; 37 public static final String COMPOSE_VIEW_CLASS_NAME = "androidx.compose.ui.platform.ComposeView"; 38 public static final String ANDROID_COMPOSE_VIEW_CLASS_NAME = 39 "androidx.compose.ui.platform.AndroidComposeView"; 40 public static final String FLUTTER_VIEW_CLASS_NAME = "io.flutter.embedding.android.FlutterView"; 41 private static final String ANDROIDX_SCROLLING_VIEW_CLASS_NAME = 42 "androidx.core.view.ScrollingView"; 43 44 private static final ImmutableList<String> SCROLLABLE_CONTAINER_CLASS_NAME_LIST = 45 ImmutableList.of( 46 ADAPTER_VIEW_CLASS_NAME, 47 SCROLL_VIEW_CLASS_NAME, 48 HORIZONTAL_SCROLL_VIEW_CLASS_NAME, 49 ANDROIDX_SCROLLING_VIEW_CLASS_NAME); 50 ViewHierarchyElementUtils()51 private ViewHierarchyElementUtils() {} 52 53 /** @deprecated Use {@link #getSpeakableTextForElement(ViewHierarchyElement, Locale)} instead */ 54 @Deprecated getSpeakableTextForElement(ViewHierarchyElement element)55 public static SpannableString getSpeakableTextForElement(ViewHierarchyElement element) { 56 return getSpeakableTextForElement(element, Locale.ENGLISH); 57 } 58 59 /** 60 * Determine what text would be spoken by a screen reader for an element. 61 * 62 * @param element The element whose spoken text is desired. If it or its children are only 63 * partially initialized, this method may return additional text that would not be spoken. 64 * @param locale The that was used to produce labels for the element. This should normally be the 65 * default Locale at the time that the app was tested. 66 * @return An approximation of what a screen reader would speak for the element. This may not 67 * include any spans if the element is labeled by another element. 68 */ getSpeakableTextForElement( ViewHierarchyElement element, Locale locale)69 public static SpannableString getSpeakableTextForElement( 70 ViewHierarchyElement element, Locale locale) { 71 SpannableString speakableText = getSpeakableTextFromElementSubtree(element, locale); 72 if (element.isImportantForAccessibility()) { 73 // Determine if this element is labeled by another element 74 ViewHierarchyElement labeledBy = element.getLabeledBy(); 75 if (labeledBy != null) { 76 SpannableString label = getSpeakableElementTextOrLabel(labeledBy); 77 if (!TextUtils.isEmpty(label)) { 78 // Assumes that caller is not interested in any spans that may appear within 'label' or 79 // 'speakableText'. 80 return new SpannableString( 81 String.format( 82 locale, 83 StringManager.getString(locale, "template_labeled_item"), 84 speakableText, 85 label), 86 ImmutableList.of()); 87 } 88 } 89 } 90 return speakableText; 91 } 92 93 /** 94 * Determine what text would be spoken by a screen reader for an element and its subtree, 95 * disregarding other labeling relationships within the hierarchy. 96 * 97 * @param element The element whose spoken text is desired 98 * @return An approximation of what a screen reader would speak for the element and its subtree 99 */ getSpeakableTextFromElementSubtree( ViewHierarchyElement element, Locale locale)100 private static SpannableString getSpeakableTextFromElementSubtree( 101 ViewHierarchyElement element, Locale locale) { 102 if (element.checkInstanceOf(TOGGLE_BUTTON_CLASS_NAME) 103 || element.checkInstanceOf(SWITCH_CLASS_NAME)) { 104 return ruleSwitch(element, locale); 105 } 106 107 SpannableStringBuilder returnStringBuilder = new SpannableStringBuilder(); 108 if (element.isImportantForAccessibility()) { 109 CharSequence stateDescription = getDescriptionForTreeStatus(element, locale); 110 if (stateDescription != null) { 111 returnStringBuilder.appendWithSeparator(stateDescription); 112 } 113 114 // Content descriptions override everything else -- including children 115 SpannableString contentDescription = element.getContentDescription(); 116 if (!TextUtils.isEmpty(contentDescription)) { 117 return returnStringBuilder.appendWithSeparator(contentDescription).build(); 118 } 119 120 SpannableString text = element.getText(); 121 if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) { 122 returnStringBuilder.appendWithSeparator(text); 123 } 124 125 if (element.checkInstanceOf(ABS_LIST_VIEW_CLASS_NAME) && element.getChildViewCount() == 0) { 126 returnStringBuilder.appendWithSeparator( 127 String.format( 128 locale, 129 StringManager.getString(locale, "template_containers_quantity_other"), 130 StringManager.getString(locale, "value_listview"), 131 0)); 132 } 133 } 134 135 /* Collect speakable text from children */ 136 for (int i = 0; i < element.getChildViewCount(); ++i) { 137 ViewHierarchyElement child = element.getChildView(i); 138 if (!isFocusableOrClickableForAccessibility(child)) { 139 SpannableString childDesc = getSpeakableTextFromElementSubtree(child, locale); 140 if (!TextUtils.isEmpty(childDesc)) { 141 returnStringBuilder.appendWithSeparator(childDesc); 142 } 143 } 144 } 145 146 if (element.isImportantForAccessibility()) { 147 SpannableString hint = element.getHintText(); 148 if (!TextUtils.isEmpty(hint) && (TextUtils.getTrimmedLength(hint) > 0)) { 149 returnStringBuilder.appendWithSeparator(hint); 150 } 151 } 152 153 return returnStringBuilder.build(); 154 } 155 156 /** Gets the state description for an element that is not a Switch or ToggleButton. */ getDescriptionForTreeStatus( ViewHierarchyElement element, Locale locale)157 private static @Nullable CharSequence getDescriptionForTreeStatus( 158 ViewHierarchyElement element, Locale locale) { 159 if (element.getStateDescription() != null) { 160 return element.getStateDescription(); 161 } 162 163 if (TRUE.equals(element.isCheckable())) { 164 if (TRUE.equals(element.isChecked())) { 165 return StringManager.getString(locale, "value_checked"); 166 } else if (FALSE.equals(element.isChecked())) { 167 return StringManager.getString(locale, "value_not_checked"); 168 } 169 } 170 return null; 171 } 172 ruleSwitch(ViewHierarchyElement element, Locale locale)173 private static SpannableString ruleSwitch(ViewHierarchyElement element, Locale locale) { 174 if (element.isImportantForAccessibility()) { 175 return dedupeJoin(getSwitchState(element, locale), getSwitchContent(element)); 176 } 177 return new SpannableString("", ImmutableList.of()); // Empty string 178 } 179 getSwitchContent(ViewHierarchyElement element)180 private static @Nullable CharSequence getSwitchContent(ViewHierarchyElement element) { 181 SpannableString contentDescription = element.getContentDescription(); 182 if (!TextUtils.isEmpty(contentDescription)) { 183 return contentDescription; 184 } 185 186 CharSequence stateDescription = element.getStateDescription(); 187 SpannableString text = element.getText(); 188 if ((stateDescription != null) 189 && !TextUtils.isEmpty(text) 190 && (TextUtils.getTrimmedLength(text) > 0)) { 191 return text; 192 } 193 194 return null; 195 } 196 getSwitchState( ViewHierarchyElement element, Locale locale)197 private static @Nullable CharSequence getSwitchState( 198 ViewHierarchyElement element, Locale locale) { 199 if (element.getStateDescription() != null) { 200 return element.getStateDescription(); 201 } 202 203 SpannableString text = element.getText(); 204 if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) { 205 return text; 206 } 207 208 if (TRUE.equals(element.isChecked())) { 209 return StringManager.getString(locale, "value_on"); 210 } else if (FALSE.equals(element.isChecked())) { 211 return StringManager.getString(locale, "value_off"); 212 } 213 return null; 214 } 215 216 /** 217 * Determine speakable text for an individual element, suitable for use as a label. 218 * 219 * @param element The element whose spoken text is desired 220 * @return An approximation of what a screen reader would speak for the element 221 */ getSpeakableElementTextOrLabel( ViewHierarchyElement element)222 private static @Nullable SpannableString getSpeakableElementTextOrLabel( 223 ViewHierarchyElement element) { 224 if (element.isImportantForAccessibility()) { 225 SpannableString contentDescription = element.getContentDescription(); 226 if (!TextUtils.isEmpty(contentDescription)) { 227 return contentDescription; 228 } 229 230 SpannableString text = element.getText(); 231 if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) { 232 return text; 233 } 234 } 235 return null; 236 } 237 238 /** 239 * Determines if the supplied {@link ViewHierarchyElement} would be focused during navigation 240 * operations with a screen reader. 241 * 242 * @param view The {@link ViewHierarchyElement} to evaluate 243 * @return {@code true} if a screen reader would choose to place accessibility focus on {@code 244 * view}, {@code false} otherwise. 245 */ shouldFocusView(ViewHierarchyElement view)246 public static boolean shouldFocusView(ViewHierarchyElement view) { 247 if (!TRUE.equals(view.isVisibleToUser())) { 248 // We don't focus views that are not visible 249 return false; 250 } 251 252 if (isAccessibilityFocusable(view)) { 253 if (!hasAnyImportantDescendant(view)) { 254 // Leaves that are accessibility focusable always gain focus regardless of presence of a 255 // spoken description. This allows unlabeled, but still actionable, widgets to be activated 256 // by the user. 257 return true; 258 } else if (isSpeakingView(view)) { 259 // The view (or its grouped non-actionable children) have content to speak. 260 return true; 261 } 262 263 return false; 264 } 265 266 if ((hasText(view) || !TextUtils.isEmpty(view.getStateDescription())) 267 && view.isImportantForAccessibility() 268 && !hasFocusableAncestor(view)) { 269 return true; 270 } 271 272 return false; 273 } 274 275 /** 276 * Returns the first ancestor of {@code view} that is focusable for accessibility. If no such 277 * ancestor exists, returns {@code null}. First means the ancestor closest to {@code view}, not 278 * the ancestor closest to the root of the view hierarchy. If {@code view} itself is accessibility 279 * focusable, returns {@code view}. 280 * 281 * @param view The {@link ViewHierarchyElement} to evaluate. 282 * @return The first ancestor of {@code view} that is accessibility focusable. 283 */ getFocusableForAccessibilityAncestor( ViewHierarchyElement view)284 public static @Nullable ViewHierarchyElement getFocusableForAccessibilityAncestor( 285 ViewHierarchyElement view) { 286 ViewHierarchyElement currentView = view; 287 while ((currentView != null) && !isAccessibilityFocusable(currentView)) { 288 currentView = currentView.getParentView(); 289 } 290 return currentView; 291 } 292 293 /** 294 * Determines if the supplied {@link ViewHierarchyElement} has an ancestor which meets the 295 * criteria for gaining accessibility focus. 296 * 297 * <p>NOTE: This method only evaluates ancestors which may be considered important for 298 * accessibility and explicitly does not evaluate the supplied {@code view}. 299 * 300 * @param view The {@link ViewHierarchyElement} to evaluate 301 * @return {@code true} if an ancestor of {@code view} may gain accessibility focus, {@code false} 302 * otherwise 303 */ hasFocusableAncestor(ViewHierarchyElement view)304 private static boolean hasFocusableAncestor(ViewHierarchyElement view) { 305 ViewHierarchyElement parent = getImportantForAccessibilityAncestor(view); 306 if (parent == null) { 307 return false; 308 } 309 310 if (isAccessibilityFocusable(parent)) { 311 return true; 312 } 313 314 return hasFocusableAncestor(parent); 315 } 316 317 /** 318 * Determines if the supplied {@link ViewHierarchyElement} meets the criteria for gaining 319 * accessibility focus. 320 * 321 * @param view The {@link ViewHierarchyElement} to evaluate 322 * @return {@code true} if it is possible for {@code view} to gain accessibility focus, {@code 323 * false} otherwise. 324 */ isAccessibilityFocusable(ViewHierarchyElement view)325 private static boolean isAccessibilityFocusable(ViewHierarchyElement view) { 326 if (!TRUE.equals(view.isVisibleToUser())) { 327 return false; 328 } 329 330 if (!view.isImportantForAccessibility()) { 331 return false; 332 } 333 334 if (isFocusableOrClickableForAccessibility(view)) { 335 return true; 336 } 337 338 return isChildOfScrollableContainer(view) && isSpeakingView(view); 339 } 340 341 /** 342 * Returns whether a {@link ViewHierarchyElement} is focusable or clickable for accessibility. 343 * 344 * @param view the {@link ViewHierarchyElement} to check 345 * @return {@code true} if the view is focusable or clickable for accessibility 346 */ isFocusableOrClickableForAccessibility(ViewHierarchyElement view)347 private static boolean isFocusableOrClickableForAccessibility(ViewHierarchyElement view) { 348 return !FALSE.equals(view.isVisibleToUser()) 349 && view.isImportantForAccessibility() 350 && (view.isScreenReaderFocusable() 351 || view.isClickable() 352 || view.isFocusable() 353 || view.isLongClickable()); 354 } 355 356 /** 357 * Determines if the supplied {@link ViewHierarchyElement} is a top-level item within a scrollable 358 * container. 359 * 360 * @param view The {@link ViewHierarchyElement} to evaluate 361 * @return {@code true} if {@code view} is a top-level view within a scrollable container, {@code 362 * false} otherwise 363 */ isChildOfScrollableContainer(ViewHierarchyElement view)364 private static boolean isChildOfScrollableContainer(ViewHierarchyElement view) { 365 366 // Identify the nearest importantForAccessibility parent 367 ViewHierarchyElement parent = getImportantForAccessibilityAncestor(view); 368 369 if (parent == null) { 370 return false; 371 } 372 373 if (TRUE.equals(parent.isScrollable())) { 374 return true; 375 } 376 377 // Specifically check for parents that are AdapterView, ScrollView, or HorizontalScrollView, but 378 // exclude Spinners, which are a special case of AdapterView. TalkBack explicitly identifies 379 // views with parents matching these classes as direct children of a scrollable container. 380 if (parent.checkInstanceOf(SPINNER_CLASS_NAME)) { 381 return false; 382 } 383 384 return parent.checkInstanceOfAny(SCROLLABLE_CONTAINER_CLASS_NAME_LIST); 385 } 386 387 /** 388 * Determines if the supplied {@link ViewHierarchyElement} is one which would produce speech if it 389 * were to gain accessibility focus. <p> NOTE: This method also evaluates the subtree of the 390 * {@code view} for children that should be included in {@code view}'s spoken description. 391 * 392 * @param view The {@link ViewHierarchyElement} to evaluate 393 * @return {@code true} if a spoken description for {@code view} was determined, {@code false} 394 * otherwise. 395 */ isSpeakingView(ViewHierarchyElement view)396 private static boolean isSpeakingView(ViewHierarchyElement view) { 397 if (view.isImportantForAccessibility()) { 398 if (hasText(view)) { 399 return true; 400 } else if (TRUE.equals(view.isCheckable())) { 401 // Special case for checkable items, which screen readers may describe without text 402 return true; 403 } 404 } 405 406 if (hasNonFocusableSpeakingChildren(view)) { 407 return true; 408 } 409 410 return false; 411 } 412 413 /** 414 * Determines if the supplied {@link ViewHierarchyElement} has child view(s) which are not 415 * independently accessibility focusable and also have a spoken description. Put another way, this 416 * method determines if {@code view} has at least one child which should be included in {@code 417 * view}'s spoken description if {@code view} were to be accessibility focused. 418 * 419 * @param view The {@link ViewHierarchyElement} to evaluate 420 * @return {@code true} if {@code view} has non-actionable speaking children within its subtree 421 */ hasNonFocusableSpeakingChildren(ViewHierarchyElement view)422 private static boolean hasNonFocusableSpeakingChildren(ViewHierarchyElement view) { 423 for (int i = 0; i < view.getChildViewCount(); ++i) { 424 ViewHierarchyElement child = view.getChildView(i); 425 if ((child == null) 426 || !TRUE.equals(child.isVisibleToUser()) 427 || isAccessibilityFocusable(child)) { 428 continue; 429 } 430 431 if (isSpeakingView(child)) { 432 return true; 433 } 434 } 435 436 return false; 437 } 438 439 /** 440 * Determines if the supplied {@link ViewHierarchyElement} has a contentDescription, text or hint. 441 * 442 * @param view The {@link ViewHierarchyElement} to evaluate 443 * @return {@code true} if {@code view} has a contentDescription, text or hint, {@code false} 444 * otherwise. 445 */ hasText(ViewHierarchyElement view)446 private static boolean hasText(ViewHierarchyElement view) { 447 return !TextUtils.isEmpty(view.getText()) 448 || !TextUtils.isEmpty(view.getContentDescription()) 449 || !TextUtils.isEmpty(view.getHintText()); 450 } 451 452 /** 453 * Returns the nearest ancestor in the provided {@code view}'s lineage that is important for 454 * accessibility. 455 * 456 * @param view The {@link ViewHierarchyElement} to evaluate 457 * @return The first important for accessibility {@link ViewHierarchyElement} in {@code view}'s 458 * lineage, or {@code null} if no such ancestor exists. 459 */ getImportantForAccessibilityAncestor( ViewHierarchyElement view)460 private static @Nullable ViewHierarchyElement getImportantForAccessibilityAncestor( 461 ViewHierarchyElement view) { 462 ViewHierarchyElement parent = view.getParentView(); 463 while ((parent != null) && !parent.isImportantForAccessibility()) { 464 parent = parent.getParentView(); 465 } 466 467 return parent; 468 } 469 470 /** 471 * Determines if the provided {@code element} has any descendant, direct or indirect, which is 472 * considered important for accessibility. This is useful in determining whether or not the 473 * Android framework will attempt to reparent any child in the subtree as a direct descendant of 474 * {@code element} while converting the hierarchy to an accessibility API representation. 475 * 476 * @param element the {@link ViewHierarchyElement} to evaluate 477 * @return {@code true} if any child in {@code element}'s subtree is considered important for 478 * accessibility, {@code false} otherwise 479 */ hasAnyImportantDescendant(ViewHierarchyElement element)480 private static boolean hasAnyImportantDescendant(ViewHierarchyElement element) { 481 for (int i = 0; i < element.getChildViewCount(); ++i) { 482 ViewHierarchyElement child = element.getChildView(i); 483 if (child.isImportantForAccessibility()) { 484 return true; 485 } 486 487 if (child.getChildViewCount() > 0) { 488 if (hasAnyImportantDescendant(child)) { 489 return true; 490 } 491 } 492 } 493 494 return false; 495 } 496 497 /** 498 * Determines whether the provided {@code viewHierarchyElement} on the active window is 499 * intersected by any overlay {@link WindowHierarchyElement} whose z-order is greater than the 500 * z-order of the active window. 501 * 502 * @param viewHierarchyElement the element to check 503 * @return {@code true} if the {@code viewHierarchyElement} is intersected by any overlay {@link 504 * WindowHierarchyElement}, otherwise {@code false} 505 */ isIntersectedByOverlayWindow(ViewHierarchyElement viewHierarchyElement)506 public static boolean isIntersectedByOverlayWindow(ViewHierarchyElement viewHierarchyElement) { 507 AccessibilityHierarchy hierarchy = viewHierarchyElement.getWindow().getAccessibilityHierarchy(); 508 Integer activeWindowLayer = hierarchy.getActiveWindow().getLayer(); 509 if (activeWindowLayer == null) { 510 return false; 511 } 512 513 for (WindowHierarchyElement window : hierarchy.getAllWindows()) { 514 if ((window.getLayer() != null) && (checkNotNull(window.getLayer()) > activeWindowLayer)) { 515 if (Rect.intersects(viewHierarchyElement.getBoundsInScreen(), window.getBoundsInScreen())) { 516 return true; 517 } 518 } 519 } 520 return false; 521 } 522 523 /** 524 * Determines whether the {@code element} on the active window is known to have an intersecting 525 * overlay {@link ViewHierarchyElement} based upon their drawing orders in their parent views. 526 * 527 * @param element the element to check 528 * @return {@code true} if the element is known to have an intersecting overlay element based upon 529 * their drawing orders, otherwise {@code false} 530 * @see android.view.accessibility.AccessibilityNodeInfo#getDrawingOrder() 531 */ 532 @SuppressWarnings("ReferenceEquality") isIntersectedByOverlayView(ViewHierarchyElement element)533 public static boolean isIntersectedByOverlayView(ViewHierarchyElement element) { 534 if (element.getDrawingOrder() == null) { 535 return false; 536 } 537 538 AccessibilityHierarchy hierarchy = element.getWindow().getAccessibilityHierarchy(); 539 ViewHierarchyElement rootView = hierarchy.getActiveWindow().getRootView(); 540 541 ViewHierarchyElement view = element; 542 while (view != rootView) { 543 ViewHierarchyElement parentView = checkNotNull(view.getParentView()); 544 for (int i = 0; i < parentView.getChildViewCount(); i++) { 545 ViewHierarchyElement siblingView = parentView.getChildView(i); 546 if ((siblingView.getDrawingOrder() != null) 547 && (checkNotNull(siblingView.getDrawingOrder()) > checkNotNull(view.getDrawingOrder())) 548 && Rect.intersects(element.getBoundsInScreen(), siblingView.getBoundsInScreen())) { 549 return true; 550 } 551 } 552 view = parentView; 553 } 554 555 return false; 556 } 557 558 /** 559 * Determines whether the provided {@code viewHierarchyElement} on the active window may be 560 * obscured by other on-screen content. 561 * 562 * @param viewHierarchyElement the element to check 563 * @return {@code true} if the {@code viewHierarchyElement} may be obscured by other on-screen 564 * content, otherwise {@code false} 565 */ isPotentiallyObscured(ViewHierarchyElement viewHierarchyElement)566 public static boolean isPotentiallyObscured(ViewHierarchyElement viewHierarchyElement) { 567 return isIntersectedByOverlayWindow(viewHierarchyElement) 568 || isIntersectedByOverlayView(viewHierarchyElement); 569 } 570 dedupeJoin(@ullable CharSequence... values)571 private static SpannableString dedupeJoin(@Nullable CharSequence... values) { 572 SpannableStringBuilder returnStringBuilder = new SpannableStringBuilder(); 573 HashSet<String> uniqueValues = new HashSet<>(); 574 for (CharSequence value : values) { 575 if (TextUtils.isEmpty(value)) { 576 continue; 577 } 578 String lvalue = Ascii.toLowerCase(value.toString()); 579 if (uniqueValues.contains(lvalue)) { 580 continue; 581 } 582 uniqueValues.add(lvalue); 583 returnStringBuilder.appendWithSeparator(value); 584 } 585 return returnStringBuilder.build(); 586 } 587 } 588