// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #include "content/browser/accessibility/browser_accessibility_android.h" #include #include #include "base/i18n/break_iterator.h" #include "base/lazy_instance.h" #include "base/numerics/ranges.h" #include "base/strings/string_number_conversions.h" #include "base/strings/string_util.h" #include "base/strings/stringprintf.h" #include "base/strings/utf_string_conversions.h" #include "content/browser/accessibility/browser_accessibility_manager_android.h" #include "content/public/common/content_client.h" #include "third_party/blink/public/strings/grit/blink_strings.h" #include "third_party/skia/include/core/SkColor.h" #include "ui/accessibility/ax_assistant_structure.h" #include "ui/accessibility/ax_role_properties.h" #include "ui/accessibility/platform/ax_android_constants.h" #include "ui/accessibility/platform/ax_unique_id.h" namespace { // These are enums from android.text.InputType in Java: enum { ANDROID_TEXT_INPUTTYPE_TYPE_NULL = 0, ANDROID_TEXT_INPUTTYPE_TYPE_DATETIME = 0x4, ANDROID_TEXT_INPUTTYPE_TYPE_DATETIME_DATE = 0x14, ANDROID_TEXT_INPUTTYPE_TYPE_DATETIME_TIME = 0x24, ANDROID_TEXT_INPUTTYPE_TYPE_NUMBER = 0x2, ANDROID_TEXT_INPUTTYPE_TYPE_PHONE = 0x3, ANDROID_TEXT_INPUTTYPE_TYPE_TEXT = 0x1, ANDROID_TEXT_INPUTTYPE_TYPE_TEXT_URI = 0x11, ANDROID_TEXT_INPUTTYPE_TYPE_TEXT_WEB_EDIT_TEXT = 0xa1, ANDROID_TEXT_INPUTTYPE_TYPE_TEXT_WEB_EMAIL = 0xd1, ANDROID_TEXT_INPUTTYPE_TYPE_TEXT_WEB_PASSWORD = 0xe1 }; // These are enums from android.view.View in Java: enum { ANDROID_VIEW_VIEW_ACCESSIBILITY_LIVE_REGION_NONE = 0, ANDROID_VIEW_VIEW_ACCESSIBILITY_LIVE_REGION_POLITE = 1, ANDROID_VIEW_VIEW_ACCESSIBILITY_LIVE_REGION_ASSERTIVE = 2 }; // These are enums from // android.view.accessibility.AccessibilityNodeInfo.RangeInfo in Java: enum { ANDROID_VIEW_ACCESSIBILITY_RANGE_TYPE_FLOAT = 1 }; } // namespace namespace content { // static BrowserAccessibility* BrowserAccessibility::Create() { return new BrowserAccessibilityAndroid(); } using UniqueIdMap = std::unordered_map; // Map from each AXPlatformNode's unique id to its instance. base::LazyInstance::Leaky g_unique_id_map = LAZY_INSTANCE_INITIALIZER; // static BrowserAccessibilityAndroid* BrowserAccessibilityAndroid::GetFromUniqueId( int32_t unique_id) { UniqueIdMap* unique_ids = g_unique_id_map.Pointer(); auto iter = unique_ids->find(unique_id); if (iter != unique_ids->end()) return iter->second; return nullptr; } BrowserAccessibilityAndroid::BrowserAccessibilityAndroid() { g_unique_id_map.Get()[unique_id()] = this; } BrowserAccessibilityAndroid::~BrowserAccessibilityAndroid() { if (unique_id()) g_unique_id_map.Get().erase(unique_id()); } void BrowserAccessibilityAndroid::OnLocationChanged() { auto* manager = static_cast(this->manager()); manager->FireLocationChanged(this); } base::string16 BrowserAccessibilityAndroid::GetLocalizedStringForImageAnnotationStatus( ax::mojom::ImageAnnotationStatus status) const { // Default to standard text, except for special case of eligible. if (status != ax::mojom::ImageAnnotationStatus::kEligibleForAnnotation) return BrowserAccessibility::GetLocalizedStringForImageAnnotationStatus( status); ContentClient* content_client = content::GetContentClient(); int message_id = 0; switch (static_cast( GetIntAttribute(ax::mojom::IntAttribute::kTextDirection))) { case ax::mojom::WritingDirection::kRtl: message_id = IDS_AX_IMAGE_ELIGIBLE_FOR_ANNOTATION_ANDROID_RTL; break; case ax::mojom::WritingDirection::kTtb: case ax::mojom::WritingDirection::kBtt: case ax::mojom::WritingDirection::kNone: case ax::mojom::WritingDirection::kLtr: message_id = IDS_AX_IMAGE_ELIGIBLE_FOR_ANNOTATION_ANDROID_LTR; break; } DCHECK(message_id); return content_client->GetLocalizedString(message_id); } void BrowserAccessibilityAndroid::AppendTextToString( base::string16 extra_text, base::string16* string) const { if (extra_text.empty()) return; if (string->empty()) { *string = extra_text; return; } *string += base::string16(base::ASCIIToUTF16(", ")) + extra_text; } bool BrowserAccessibilityAndroid::IsCheckable() const { return GetData().HasCheckedState(); } bool BrowserAccessibilityAndroid::IsChecked() const { return GetData().GetCheckedState() == ax::mojom::CheckedState::kTrue; } bool BrowserAccessibilityAndroid::IsClickable() const { // If it has a custom default action verb except for // ax::mojom::DefaultActionVerb::kClickAncestor, it's definitely clickable. // ax::mojom::DefaultActionVerb::kClickAncestor is used when an element with a // click listener is present in its ancestry chain. if (HasIntAttribute(ax::mojom::IntAttribute::kDefaultActionVerb) && (GetData().GetDefaultActionVerb() != ax::mojom::DefaultActionVerb::kClickAncestor)) { return true; } if (IsHeadingLink()) return true; if (!IsEnabled()) { // TalkBack won't announce a control as disabled unless it's also marked // as clickable. In other words, Talkback wants to know if the control // might be clickable, if it wasn't disabled. return ui::IsControl(GetRole()); } // Skip web areas and iframes, they're focusable but not clickable. if (IsIframe() || (GetRole() == ax::mojom::Role::kRootWebArea)) return false; // Otherwise it's clickable if it's a control. return ui::IsControlOnAndroid(GetRole(), IsFocusable()); } bool BrowserAccessibilityAndroid::IsCollapsed() const { return HasState(ax::mojom::State::kCollapsed); } // TODO(dougt) Move to ax_role_properties? bool BrowserAccessibilityAndroid::IsCollection() const { return (ui::IsTableLike(GetRole()) || GetRole() == ax::mojom::Role::kList || GetRole() == ax::mojom::Role::kListBox || GetRole() == ax::mojom::Role::kDescriptionList || GetRole() == ax::mojom::Role::kTree); } bool BrowserAccessibilityAndroid::IsCollectionItem() const { return (GetRole() == ax::mojom::Role::kCell || GetRole() == ax::mojom::Role::kColumnHeader || GetRole() == ax::mojom::Role::kDescriptionListTerm || GetRole() == ax::mojom::Role::kListBoxOption || GetRole() == ax::mojom::Role::kListItem || GetRole() == ax::mojom::Role::kRowHeader || GetRole() == ax::mojom::Role::kTreeItem); } bool BrowserAccessibilityAndroid::IsContentInvalid() const { return HasIntAttribute(ax::mojom::IntAttribute::kInvalidState) && GetData().GetInvalidState() != ax::mojom::InvalidState::kFalse; } bool BrowserAccessibilityAndroid::IsDismissable() const { return false; // No concept of "dismissable" on the web currently. } bool BrowserAccessibilityAndroid::IsEnabled() const { switch (GetData().GetRestriction()) { case ax::mojom::Restriction::kNone: return true; case ax::mojom::Restriction::kReadOnly: case ax::mojom::Restriction::kDisabled: // On Android, both Disabled and ReadOnly are treated the same. // For both of them, we set AccessibilityNodeInfo.IsEnabled to false // and we don't expose certain actions like SET_VALUE and PASTE. return false; } NOTREACHED(); return true; } bool BrowserAccessibilityAndroid::IsExpanded() const { return HasState(ax::mojom::State::kExpanded); } bool BrowserAccessibilityAndroid::IsFocusable() const { // If it's an iframe element, or the root element of a child frame that isn't // inside a portal, only mark it as focusable if the element has an explicit // name. Otherwise mark it as not focusable to avoid the user landing on empty // container elements in the tree. if (IsIframe() || (GetRole() == ax::mojom::Role::kRootWebArea && PlatformGetParent() && PlatformGetParent()->GetRole() != ax::mojom::Role::kPortal)) return HasStringAttribute(ax::mojom::StringAttribute::kName); return HasState(ax::mojom::State::kFocusable); } bool BrowserAccessibilityAndroid::IsFocused() const { return manager()->GetFocus() == this; } bool BrowserAccessibilityAndroid::IsFormDescendant() const { // Iterate over parents and see if any are a form. const BrowserAccessibility* parent = PlatformGetParent(); while (parent != nullptr) { if (ui::IsForm(parent->GetRole())) { return true; } parent = parent->PlatformGetParent(); } return false; } bool BrowserAccessibilityAndroid::IsHeading() const { BrowserAccessibilityAndroid* parent = static_cast(PlatformGetParent()); if (parent && parent->IsHeading()) return true; return ui::IsHeadingOrTableHeader(GetRole()); } bool BrowserAccessibilityAndroid::IsHierarchical() const { return (GetRole() == ax::mojom::Role::kTree || IsHierarchicalList()); } bool BrowserAccessibilityAndroid::IsLink() const { return ui::IsLink(GetRole()); } bool BrowserAccessibilityAndroid::IsMultiLine() const { return HasState(ax::mojom::State::kMultiline); } bool BrowserAccessibilityAndroid::IsMultiselectable() const { return HasState(ax::mojom::State::kMultiselectable); } bool BrowserAccessibilityAndroid::IsReportingCheckable() const { // To communicate kMixed state Checkboxes, we will rely on state description, // so we will not report node as checkable to avoid duplicate utterances. return IsCheckable() && GetData().GetCheckedState() != ax::mojom::CheckedState::kMixed; } bool BrowserAccessibilityAndroid::IsScrollable() const { return GetBoolAttribute(ax::mojom::BoolAttribute::kScrollable); } bool BrowserAccessibilityAndroid::IsSeekControl() const { // Range types should have seek control options, except progress bars. return GetData().IsRangeValueSupported() && (GetRole() != ax::mojom::Role::kProgressIndicator); } bool BrowserAccessibilityAndroid::IsSelected() const { return GetBoolAttribute(ax::mojom::BoolAttribute::kSelected); } bool BrowserAccessibilityAndroid::IsSlider() const { return GetRole() == ax::mojom::Role::kSlider; } bool BrowserAccessibilityAndroid::IsVisibleToUser() const { return !HasState(ax::mojom::State::kInvisible); } bool BrowserAccessibilityAndroid::IsInterestingOnAndroid() const { // The root is not interesting if it doesn't have a title, even // though it's focusable. if (GetRole() == ax::mojom::Role::kRootWebArea && GetInnerText().empty()) return false; // The root inside a portal is not interesting. if (GetRole() == ax::mojom::Role::kRootWebArea && PlatformGetParent() && PlatformGetParent()->GetRole() == ax::mojom::Role::kPortal) return false; // Mark as uninteresting if it's hidden, even if it is focusable. if (HasState(ax::mojom::State::kInvisible)) return false; // Walk up the ancestry. A non-focusable child of a control is not // interesting. A child of an invisible iframe is also not interesting. const BrowserAccessibility* parent = PlatformGetParent(); while (parent != nullptr) { if (ui::IsControl(parent->GetRole()) && !IsFocusable()) return false; if (parent->GetRole() == ax::mojom::Role::kIframe && parent->GetData().HasState(ax::mojom::State::kInvisible)) { return false; } parent = parent->PlatformGetParent(); } // Otherwise, focusable nodes are always interesting. Note that IsFocusable() // already skips over things like iframes and child frames that are // technically focusable but shouldn't be exposed as focusable on Android. if (IsFocusable()) return true; // If it's not focusable but has a control role, then it's interesting. if (ui::IsControl(GetRole())) return true; // Mark progress indicators as interesting, since they are not focusable and // not a control, but users should be able to swipe/navigate to them. if (GetRole() == ax::mojom::Role::kProgressIndicator) return true; // If we are the direct descendant of a link and have no siblings/children, // then we are not interesting, return false parent = PlatformGetParent(); if (parent != nullptr && ui::IsLink(parent->GetRole()) && parent->PlatformChildCount() == 1 && PlatformChildCount() == 0) { return false; } // Otherwise, the interesting nodes are leaf nodes with non-whitespace text. return IsLeaf() && !base::ContainsOnlyChars(GetInnerText(), base::kWhitespaceUTF16); } bool BrowserAccessibilityAndroid::IsHeadingLink() const { if (!(GetRole() == ax::mojom::Role::kHeading && InternalChildCount() == 1)) return false; BrowserAccessibilityAndroid* child = static_cast(InternalChildrenBegin().get()); return child->IsLink(); } const BrowserAccessibilityAndroid* BrowserAccessibilityAndroid::GetSoleInterestingNodeFromSubtree() const { if (IsInterestingOnAndroid()) return this; const BrowserAccessibilityAndroid* sole_interesting_node = nullptr; for (PlatformChildIterator it = PlatformChildrenBegin(); it != PlatformChildrenEnd(); ++it) { const BrowserAccessibilityAndroid* interesting_node = static_cast(it.get()) ->GetSoleInterestingNodeFromSubtree(); if (interesting_node && sole_interesting_node) { // If there are two interesting nodes, return nullptr. return nullptr; } else if (interesting_node) { sole_interesting_node = interesting_node; } } return sole_interesting_node; } bool BrowserAccessibilityAndroid::AreInlineTextBoxesLoaded() const { if (IsText()) return InternalChildCount() > 0; // Return false if any descendant needs to load inline text boxes. for (auto it = InternalChildrenBegin(); it != InternalChildrenEnd(); ++it) { BrowserAccessibilityAndroid* child = static_cast(it.get()); if (!child->AreInlineTextBoxesLoaded()) return false; } // Otherwise return true - either they're all loaded, or there aren't // any descendants that need to load inline text boxes. return true; } bool BrowserAccessibilityAndroid::CanOpenPopup() const { return HasIntAttribute(ax::mojom::IntAttribute::kHasPopup); } const char* BrowserAccessibilityAndroid::GetClassName() const { ax::mojom::Role role = GetRole(); // On Android, contenteditable needs to be handled the same as any // other text field. if (IsTextField()) role = ax::mojom::Role::kTextField; return ui::AXRoleToAndroidClassName(role, PlatformGetParent() != nullptr); } bool BrowserAccessibilityAndroid::IsChildOfLeaf() const { BrowserAccessibility* ancestor = InternalGetParent(); while (ancestor) { if (ancestor->IsLeaf()) return true; ancestor = ancestor->InternalGetParent(); } return false; } bool BrowserAccessibilityAndroid::IsLeaf() const { if (BrowserAccessibility::IsLeaf()) return true; // Iframes are always allowed to contain children. if (IsIframe() || GetRole() == ax::mojom::Role::kRootWebArea || GetRole() == ax::mojom::Role::kWebArea) { return false; } // Button, date and time controls should drop their children. switch (GetRole()) { case ax::mojom::Role::kButton: case ax::mojom::Role::kDate: case ax::mojom::Role::kDateTime: case ax::mojom::Role::kInputTime: return true; default: break; } // Links are never leaves. if (IsLink()) return false; // If it has a focusable child, we definitely can't leave out children. if (HasFocusableNonOptionChild()) return false; BrowserAccessibilityManagerAndroid* manager_android = static_cast(manager()); if (manager_android->prune_tree_for_screen_reader()) { // Headings with text can drop their children. base::string16 name = GetInnerText(); if (GetRole() == ax::mojom::Role::kHeading && !name.empty()) return true; // Focusable nodes with text can drop their children. if (HasState(ax::mojom::State::kFocusable) && !name.empty()) return true; // Nodes with only static text as children can drop their children. if (HasOnlyTextChildren()) return true; } return false; } base::string16 BrowserAccessibilityAndroid::GetInnerText() const { if (IsIframe() || GetRole() == ax::mojom::Role::kWebArea) { return base::string16(); } // First, always return the |value| attribute if this is an // input field. base::string16 value = GetValueForControl(); if (ShouldExposeValueAsName()) return value; // For color wells, the color is stored in separate attributes. // Perhaps we could return color names in the future? if (GetRole() == ax::mojom::Role::kColorWell) { unsigned int color = static_cast( GetIntAttribute(ax::mojom::IntAttribute::kColorValue)); unsigned int red = SkColorGetR(color); unsigned int green = SkColorGetG(color); unsigned int blue = SkColorGetB(color); return base::UTF8ToUTF16( base::StringPrintf("#%02X%02X%02X", red, green, blue)); } base::string16 text = GetNameAsString16(); if (text.empty()) text = value; // If this is the root element, give up now, allow it to have no // accessible text. For almost all other focusable nodes we try to // get text from contents, but for the root element that's redundant // and often way too verbose. if (GetRole() == ax::mojom::Role::kRootWebArea) return text; // Append image description strings to the text. auto status = GetData().GetImageAnnotationStatus(); switch (status) { case ax::mojom::ImageAnnotationStatus::kEligibleForAnnotation: case ax::mojom::ImageAnnotationStatus::kAnnotationPending: case ax::mojom::ImageAnnotationStatus::kAnnotationEmpty: case ax::mojom::ImageAnnotationStatus::kAnnotationAdult: case ax::mojom::ImageAnnotationStatus::kAnnotationProcessFailed: AppendTextToString(GetLocalizedStringForImageAnnotationStatus(status), &text); break; case ax::mojom::ImageAnnotationStatus::kAnnotationSucceeded: AppendTextToString( GetString16Attribute(ax::mojom::StringAttribute::kImageAnnotation), &text); break; case ax::mojom::ImageAnnotationStatus::kNone: case ax::mojom::ImageAnnotationStatus::kWillNotAnnotateDueToScheme: case ax::mojom::ImageAnnotationStatus::kIneligibleForAnnotation: case ax::mojom::ImageAnnotationStatus::kSilentlyEligibleForAnnotation: break; } // This is called from IsLeaf, so don't call PlatformChildCount // from within this! if (text.empty() && (HasOnlyTextChildren() || (IsFocusable() && HasOnlyTextAndImageChildren()))) { for (auto it = InternalChildrenBegin(); it != InternalChildrenEnd(); ++it) { text += static_cast(it.get())->GetInnerText(); } } if (text.empty() && (ui::IsLink(GetRole()) || ui::IsImageOrVideo(GetRole())) && !HasExplicitlyEmptyName()) { base::string16 url = GetString16Attribute(ax::mojom::StringAttribute::kUrl); text = ui::AXUrlBaseText(url); } return text; } base::string16 BrowserAccessibilityAndroid::GetValueForControl() const { base::string16 value = BrowserAccessibility::GetValueForControl(); // Optionally replace entered password text with bullet characters // based on a user preference. if (IsPasswordField()) { auto* manager = static_cast(this->manager()); if (manager->ShouldRespectDisplayedPasswordText()) { // In the Chrome accessibility tree, the value of a password node is // unobscured. However, if ShouldRespectDisplayedPasswordText() returns // true we should try to expose whatever's actually visually displayed, // whether that's the actual password or dots or whatever. To do this // we rely on the password field's shadow dom. value = BrowserAccessibility::GetInnerText(); } else if (!manager->ShouldExposePasswordText()) { value = base::string16(value.size(), ui::kSecurePasswordBullet); } } return value; } base::string16 BrowserAccessibilityAndroid::GetHint() const { std::vector strings; // If we're returning the value as the main text, the name needs to be // part of the hint. if (ShouldExposeValueAsName()) { base::string16 name = GetNameAsString16(); if (!name.empty()) strings.push_back(name); } if (GetData().GetNameFrom() != ax::mojom::NameFrom::kPlaceholder) { base::string16 placeholder = GetString16Attribute(ax::mojom::StringAttribute::kPlaceholder); if (!placeholder.empty()) strings.push_back(placeholder); } base::string16 description = GetString16Attribute(ax::mojom::StringAttribute::kDescription); if (!description.empty()) strings.push_back(description); return base::JoinString(strings, base::ASCIIToUTF16(" ")); } base::string16 BrowserAccessibilityAndroid::GetStateDescription() const { std::vector state_descs; // For multiselectable state, generate a state description. We do not set a // state description for pop up/