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