blob: d65a3911120071d29e9f1369fcbe344f4c676ef8 [file] [log] [blame]
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.ui.platform
import android.content.Context
import android.graphics.RectF
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.ViewParent
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityManager
import android.view.accessibility.AccessibilityNodeInfo
import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH
import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX
import android.view.accessibility.AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY
import android.view.accessibility.AccessibilityNodeProvider
import androidx.annotation.IntRange
import androidx.collection.SparseArrayCompat
import androidx.compose.ui.R
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.findChildById
import androidx.compose.ui.semantics.getAllSemanticsNodesToMap
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsActions.CustomActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.length
import androidx.compose.ui.unit.toRect
import androidx.compose.ui.util.fastForEach
internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidComposeView) :
AccessibilityDelegateCompat() {
companion object {
/** Virtual node identifier value for invalid nodes. */
const val InvalidId = Integer.MIN_VALUE
const val ClassName = "android.view.View"
const val LogTag = "AccessibilityDelegate"
/**
* Intent size limitations prevent sending over a megabyte of data. Limit
* text length to 100K characters - 200KB.
*/
const val ParcelSafeTextLength = 100000
/**
* The undefined cursor position.
*/
private val AccessibilityCursorPositionUndefined = -1
private val AccessibilityActionsResourceIds = intArrayOf(
R.id.accessibility_custom_action_0,
R.id.accessibility_custom_action_1,
R.id.accessibility_custom_action_2,
R.id.accessibility_custom_action_3,
R.id.accessibility_custom_action_4,
R.id.accessibility_custom_action_5,
R.id.accessibility_custom_action_6,
R.id.accessibility_custom_action_7,
R.id.accessibility_custom_action_8,
R.id.accessibility_custom_action_9,
R.id.accessibility_custom_action_10,
R.id.accessibility_custom_action_11,
R.id.accessibility_custom_action_12,
R.id.accessibility_custom_action_13,
R.id.accessibility_custom_action_14,
R.id.accessibility_custom_action_15,
R.id.accessibility_custom_action_16,
R.id.accessibility_custom_action_17,
R.id.accessibility_custom_action_18,
R.id.accessibility_custom_action_19,
R.id.accessibility_custom_action_20,
R.id.accessibility_custom_action_21,
R.id.accessibility_custom_action_22,
R.id.accessibility_custom_action_23,
R.id.accessibility_custom_action_24,
R.id.accessibility_custom_action_25,
R.id.accessibility_custom_action_26,
R.id.accessibility_custom_action_27,
R.id.accessibility_custom_action_28,
R.id.accessibility_custom_action_29,
R.id.accessibility_custom_action_30,
R.id.accessibility_custom_action_31
)
}
/** Virtual view id for the currently hovered logical item. */
private var hoveredVirtualViewId = InvalidId
private val accessibilityManager: AccessibilityManager =
view.context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
private val handler = Handler(Looper.getMainLooper())
private var nodeProvider: AccessibilityNodeProviderCompat =
AccessibilityNodeProviderCompat(MyNodeProvider())
private var focusedVirtualViewId = InvalidId
// For actionIdToId and labelToActionId, the keys are the virtualViewIds. The value of
// actionIdToLabel holds assigned custom action id to custom action label mapping. The
// value of labelToActionId holds custom action label to assigned custom action id mapping.
private var actionIdToLabel = SparseArrayCompat<SparseArrayCompat<CharSequence>>()
private var labelToActionId = SparseArrayCompat<Map<CharSequence, Int>>()
private var accessibilityCursorPosition = AccessibilityCursorPositionUndefined
private class SemanticsNodeCopy(
semanticsNode: SemanticsNode
) {
val config = semanticsNode.config
val children: MutableSet<Int> = mutableSetOf()
init {
semanticsNode.children.fastForEach { child ->
children.add(child.id)
}
}
}
private var semanticsNodes: MutableMap<Int, SemanticsNodeCopy> = mutableMapOf()
private var semanticsRoot = SemanticsNodeCopy(view.semanticsOwner.rootSemanticsNode)
private var checkingForSemanticsChanges = false
init {
// Remove callbacks that rely on view being attached to a window when we become detached.
view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
override fun onViewAttachedToWindow(view: View) {}
override fun onViewDetachedFromWindow(view: View) {
handler.removeCallbacks(semanticsChangeChecker)
}
})
}
private fun createNodeInfo(virtualViewId: Int): AccessibilityNodeInfo {
val info: AccessibilityNodeInfoCompat = AccessibilityNodeInfoCompat.obtain()
// the hidden property is often not there
info.isVisibleToUser = true
val semanticsNode: SemanticsNode?
if (virtualViewId == AccessibilityNodeProviderCompat.HOST_VIEW_ID) {
info.setSource(view)
semanticsNode = view.semanticsOwner.rootSemanticsNode
info.setParent(ViewCompat.getParentForAccessibility(view) as? View)
} else {
semanticsNode = view.semanticsOwner.rootSemanticsNode.findChildById(virtualViewId)
if (semanticsNode == null) {
// throw IllegalStateException("Semantics node $virtualViewId is not attached")
return info.unwrap()
}
info.setSource(view, semanticsNode.id)
// TODO(b/154023028): Semantics: Immediate children of the root node report parent ==
// null
if (semanticsNode.parent != null) {
var parentId = semanticsNode.parent!!.id
if (parentId == view.semanticsOwner.rootSemanticsNode.id) {
parentId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
}
info.setParent(view, parentId)
} else {
// throw IllegalStateException("semanticsNode $virtualViewId has null parent")
}
}
// TODO(b/151240295): Should we have widgets class name?
info.className = ClassName
info.packageName = view.context.packageName
try {
info.setBoundsInScreen(
android.graphics.Rect(
semanticsNode.globalBounds.left.toInt(),
semanticsNode.globalBounds.top.toInt(),
semanticsNode.globalBounds.right.toInt(),
semanticsNode.globalBounds.bottom.toInt()
)
)
} catch (e: IllegalStateException) {
// We may get "Asking for measurement result of unmeasured layout modifier" error.
// TODO(b/153198816): check whether we still get this exception when R is in.
info.setBoundsInScreen(android.graphics.Rect())
}
for (child in semanticsNode.children) {
info.addChild(view, child.id)
}
// Manage internal accessibility focus state.
if (focusedVirtualViewId == virtualViewId) {
info.isAccessibilityFocused = true
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat
.ACTION_CLEAR_ACCESSIBILITY_FOCUS
)
} else {
info.isAccessibilityFocused = false
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat
.ACTION_ACCESSIBILITY_FOCUS
)
}
// TODO: we need a AnnotedString to CharSequence conversion function
info.text = trimToSize(semanticsNode.config.getOrNull(SemanticsProperties.Text)?.text,
ParcelSafeTextLength)
info.stateDescription =
semanticsNode.config.getOrNull(SemanticsProperties.AccessibilityValue)
info.contentDescription =
semanticsNode.config.getOrNull(SemanticsProperties.AccessibilityLabel)
// Note editable is not added to semantics properties api.
info.isEditable = semanticsNode.config.contains(SemanticsActions.SetText)
info.isEnabled = (semanticsNode.config.getOrNull(SemanticsProperties.Disabled) == null)
info.isFocusable = semanticsNode.config.contains(SemanticsProperties.Focused)
if (info.isFocusable) {
info.isFocused = semanticsNode.config[SemanticsProperties.Focused]
}
info.isVisibleToUser = (semanticsNode.config.getOrNull(SemanticsProperties.Hidden) == null)
info.isClickable = semanticsNode.config.contains(SemanticsActions.OnClick)
if (info.isClickable) {
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK
)
}
if (semanticsNode.config.contains(SemanticsActions.SetProgress)) {
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_PROGRESS
)
}
// TODO(b/157692376): remove scroll forward/backward api together with slider scroll action.
@Suppress("DEPRECATION")
if (semanticsNode.config.contains(SemanticsActions.ScrollForward)) {
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_FORWARD
)
}
@Suppress("DEPRECATION")
if (semanticsNode.config.contains(SemanticsActions.ScrollBackward)) {
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_BACKWARD
)
}
if (semanticsNode.config.contains(SemanticsActions.SetText)) {
info.className = "android.widget.EditText"
info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_TEXT)
}
if (semanticsNode.config.contains(SemanticsActions.SetSelection)) {
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_SELECTION
)
}
val text = getIterableTextForAccessibility(semanticsNode)
if (!text.isNullOrEmpty()) {
info.setTextSelection(
getAccessibilitySelectionStart(semanticsNode),
getAccessibilitySelectionEnd(semanticsNode)
)
info.addAction(AccessibilityNodeInfoCompat.ACTION_SET_SELECTION)
info.addAction(AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY)
info.addAction(AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY)
info.movementGranularities =
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH
// We only traverse the text when accessibilityLabel is not set.
if (info.contentDescription.isNullOrEmpty() &&
semanticsNode.config.contains(SemanticsActions.GetTextLayoutResult)) {
info.movementGranularities = info.movementGranularities or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PAGE
}
}
if (Build.VERSION.SDK_INT >= 26 && !info.text.isNullOrEmpty() &&
semanticsNode.config.contains(SemanticsActions.GetTextLayoutResult)) {
info.unwrap().availableExtraData = listOf(EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY)
}
val rangeInfo =
semanticsNode.config.getOrNull(SemanticsProperties.AccessibilityRangeInfo)
if (rangeInfo != null) {
info.rangeInfo = AccessibilityNodeInfoCompat.RangeInfoCompat.obtain(
AccessibilityNodeInfoCompat.RangeInfoCompat.RANGE_TYPE_FLOAT,
rangeInfo.range.start, rangeInfo.range.endInclusive, rangeInfo.current
)
}
if (semanticsNode.config.contains(CustomActions)) {
val customActions = semanticsNode.config[CustomActions]
if (customActions.size >= AccessibilityActionsResourceIds.size) {
throw IllegalStateException(
"Can't have more than " +
"${AccessibilityActionsResourceIds.size} custom actions for one widget"
)
}
val currentActionIdToLabel = SparseArrayCompat<CharSequence>()
val currentLabelToActionId = mutableMapOf<CharSequence, Int>()
// If this virtual node had custom action id assignment before, we try to keep the id
// unchanged for the same action (identified by action label). This way, we can
// minimize the influence of custom action change between custom actions are
// presented to the user and actually performed.
if (labelToActionId.containsKey(virtualViewId)) {
val oldLabelToActionId = labelToActionId[virtualViewId]
val availableIds = AccessibilityActionsResourceIds.toMutableList()
val unassignedActions = mutableListOf<CustomAccessibilityAction>()
for (action in customActions) {
if (oldLabelToActionId!!.contains(action.label)) {
val actionId = oldLabelToActionId[action.label]
currentActionIdToLabel.put(actionId!!, action.label)
currentLabelToActionId[action.label] = actionId
availableIds.remove(actionId)
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
actionId, action.label
)
)
} else {
unassignedActions.add(action)
}
}
for ((index, action) in unassignedActions.withIndex()) {
val actionId = availableIds[index]
currentActionIdToLabel.put(actionId, action.label)
currentLabelToActionId[action.label] = actionId
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
actionId, action.label
)
)
}
} else {
for ((index, action) in customActions.withIndex()) {
val actionId = AccessibilityActionsResourceIds[index]
currentActionIdToLabel.put(actionId, action.label)
currentLabelToActionId[action.label] = actionId
info.addAction(
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
actionId, action.label
)
)
}
}
actionIdToLabel.put(virtualViewId, currentActionIdToLabel)
labelToActionId.put(virtualViewId, currentLabelToActionId)
}
return info.unwrap()
}
/**
* Returns whether this virtual view is accessibility focused.
*
* @return True if the view is accessibility focused.
*/
private fun isAccessibilityFocused(virtualViewId: Int): Boolean {
return (focusedVirtualViewId == virtualViewId)
}
/**
* Attempts to give accessibility focus to a virtual view.
* <p>
* A virtual view will not actually take focus if
* {@link AccessibilityManager#isEnabled()} returns false,
* {@link AccessibilityManager#isTouchExplorationEnabled()} returns false,
* or the view already has accessibility focus.
*
* @param virtualViewId The id of the virtual view on which to place
* accessibility focus.
* @return Whether this virtual view actually took accessibility focus.
*/
private fun requestAccessibilityFocus(virtualViewId: Int): Boolean {
if (!accessibilityManager.isEnabled ||
!accessibilityManager.isTouchExplorationEnabled
) {
return false
}
// TODO: Check virtual view visibility.
if (!isAccessibilityFocused(virtualViewId)) {
// Clear focus from the previously focused view, if applicable.
if (focusedVirtualViewId != InvalidId) {
sendEventForVirtualView(
focusedVirtualViewId,
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED,
null,
null
)
}
// Set focus on the new view.
focusedVirtualViewId = virtualViewId
view.invalidate()
sendEventForVirtualView(
virtualViewId,
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED,
null,
null
)
return true
}
return false
}
/**
* Populates an event of the specified type with information about an item
* and attempts to send it up through the view hierarchy.
* <p>
* You should call this method after performing a user action that normally
* fires an accessibility event, such as clicking on an item.
*
* <pre>public performItemClick(T item) {
* ...
* sendEventForVirtualView(item.id, AccessibilityEvent.TYPE_VIEW_CLICKED)
* }
* </pre>
*
* @param virtualViewId The virtual view id for which to send an event.
* @param eventType The type of event to send.
* @param contentChangeType The contentChangeType of this event.
* @param contentDescription Content description of this event.
* @return true if the event was sent successfully.
*/
private fun sendEventForVirtualView(
virtualViewId: Int,
eventType: Int,
contentChangeType: Int? = null,
contentDescription: CharSequence? = null
): Boolean {
if ((virtualViewId == InvalidId) || !accessibilityManager.isEnabled) {
return false
}
val parent: ViewParent = view.parent
val event: AccessibilityEvent = createEvent(virtualViewId, eventType)
if (contentChangeType != null) {
event.contentChangeTypes = contentChangeType
}
if (contentDescription != null) {
event.contentDescription = contentDescription
}
return parent.requestSendAccessibilityEvent(view, event)
}
/**
* Send an accessibility event.
*
* @param event The accessibility event to send.
* @return true if the event was sent successfully.
*/
private fun sendEvent(event: AccessibilityEvent): Boolean {
if (!accessibilityManager.isEnabled) {
return false
}
return view.parent.requestSendAccessibilityEvent(view, event)
}
/**
* Constructs and returns an {@link AccessibilityEvent} populated with
* information about the specified item.
*
* @param virtualViewId The virtual view id for the item for which to
* construct an event.
* @param eventType The type of event to construct.
* @return An {@link AccessibilityEvent} populated with information about
* the specified item.
*/
private fun createEvent(virtualViewId: Int, eventType: Int): AccessibilityEvent {
val event: AccessibilityEvent = AccessibilityEvent.obtain(eventType)
event.isEnabled = true
event.className = ClassName
// Don't allow the client to override these properties.
event.packageName = view.context.packageName
event.setSource(view, virtualViewId)
return event
}
/**
* Attempts to clear accessibility focus from a virtual view.
*
* @param virtualViewId The id of the virtual view from which to clear
* accessibility focus.
* @return Whether this virtual view actually cleared accessibility focus.
*/
private fun clearAccessibilityFocus(virtualViewId: Int): Boolean {
if (isAccessibilityFocused(virtualViewId)) {
focusedVirtualViewId = InvalidId
view.invalidate()
sendEventForVirtualView(
virtualViewId,
AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED,
null,
null
)
return true
}
return false
}
private fun performActionHelper(
virtualViewId: Int,
action: Int,
arguments: Bundle?
): Boolean {
val node: SemanticsNode =
if (virtualViewId == AccessibilityNodeProviderCompat.HOST_VIEW_ID) {
view.semanticsOwner.rootSemanticsNode
} else {
view.semanticsOwner.rootSemanticsNode.findChildById(virtualViewId) ?: return false
}
when (action) {
AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS ->
return requestAccessibilityFocus(virtualViewId)
AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS ->
return clearAccessibilityFocus(virtualViewId)
AccessibilityNodeInfoCompat.ACTION_CLICK -> {
return if (node.config.contains(SemanticsActions.OnClick)) {
node.config[SemanticsActions.OnClick].action()
} else {
false
}
}
AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD -> {
// TODO(b/157692376): remove scroll forward/backward api together with slider
// scroll action.
@Suppress("DEPRECATION")
return if (node.config.contains(SemanticsActions.ScrollForward)) {
node.config[SemanticsActions.ScrollForward].action()
} else {
false
}
}
AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD -> {
@Suppress("DEPRECATION")
return if (node.config.contains(SemanticsActions.ScrollBackward)) {
node.config[SemanticsActions.ScrollBackward].action()
} else {
false
}
}
android.R.id.accessibilityActionSetProgress -> {
if (arguments == null || !arguments.containsKey(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE
)
) {
return false
}
return if (node.config.contains(SemanticsActions.SetProgress)) {
node.config[SemanticsActions.SetProgress].action(
arguments.getFloat(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_PROGRESS_VALUE
)
)
} else {
false
}
}
AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY -> {
if (arguments != null) {
val granularity = arguments.getInt(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT)
val extendSelection = arguments.getBoolean(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN)
return traverseAtGranularity(node, granularity,
action == AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY,
extendSelection)
}
return false
}
AccessibilityNodeInfoCompat.ACTION_SET_SELECTION -> {
val start = arguments?.getInt(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_SELECTION_START_INT, -1) ?: -1
val end = arguments?.getInt(
AccessibilityNodeInfoCompat.ACTION_ARGUMENT_SELECTION_END_INT, -1) ?: -1
// Note: This is a little different from current android framework implementation.
val success = setAccessibilitySelection(node, start, end)
// Text selection changed event already updates the cache. so this may not be
// necessary.
if (success) {
sendEventForVirtualView(
semanticsNodeIdToAccessibilityVirtualNodeId(node.id),
AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
)
}
return success
}
// TODO: handling for other system actions
else -> {
val label = actionIdToLabel[virtualViewId]?.get(action) ?: return false
val customActions = node.config.getOrNull(CustomActions) ?: return false
for (customAction in customActions) {
if (customAction.label == label) {
return customAction.action()
}
}
return false
}
}
}
private fun addExtraDataToAccessibilityNodeInfoHelper(
virtualViewId: Int,
info: AccessibilityNodeInfo,
extraDataKey: String,
arguments: Bundle?
) {
val node: SemanticsNode =
if (virtualViewId == AccessibilityNodeProviderCompat.HOST_VIEW_ID) {
view.semanticsOwner.rootSemanticsNode
} else {
view.semanticsOwner.rootSemanticsNode.findChildById(virtualViewId) ?: return
}
// TODO(b/157474582): This only works for single text/text field
if (node.config.contains(SemanticsProperties.Text) &&
node.config.contains(SemanticsActions.GetTextLayoutResult) &&
arguments != null && extraDataKey == EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY) {
val positionInfoStartIndex = arguments.getInt(
EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, -1
)
val positionInfoLength = arguments.getInt(
EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH, -1
)
if ((positionInfoLength <= 0) || (positionInfoStartIndex < 0) ||
(positionInfoStartIndex >= node.config[SemanticsProperties.Text].length)) {
Log.e(LogTag, "Invalid arguments for accessibility character locations")
return
}
val textLayoutResults = mutableListOf<TextLayoutResult>()
// Note now it only works for single Text/TextField until we fix the merging issue.
val textLayoutResult: TextLayoutResult
if (node.config[SemanticsActions.GetTextLayoutResult].action(textLayoutResults)) {
textLayoutResult = textLayoutResults[0]
} else {
return
}
val boundingRects = mutableListOf<RectF?>()
val textNode: SemanticsNode? = node.findNonEmptyTextChild()
for (i in 0 until positionInfoLength) {
val bounds = textLayoutResult.getBoundingBox(positionInfoStartIndex + i)
val screenBounds: Rect?
// Only the visible/partial visible locations are used.
if (textNode != null) {
screenBounds = toScreenCoords(textNode, bounds)
} else {
screenBounds = bounds
}
if (screenBounds == null) {
boundingRects.add(null)
} else {
boundingRects.add(
RectF(
screenBounds.left,
screenBounds.top,
screenBounds.right,
screenBounds.bottom
)
)
}
}
info.extras.putParcelableArray(extraDataKey, boundingRects.toTypedArray())
}
}
private fun toScreenCoords(textNode: SemanticsNode, bounds: Rect): Rect? {
val screenBounds = bounds.shift(textNode.globalPosition)
val globalBounds = textNode.globalBounds.toRect()
if (screenBounds.overlaps(globalBounds)) {
return screenBounds.intersect(globalBounds)
}
return null
}
// TODO: this only works for single text/text field.
private fun SemanticsNode.findNonEmptyTextChild(): SemanticsNode? {
if (this.unmergedConfig.contains(SemanticsProperties.Text) &&
this.unmergedConfig[SemanticsProperties.Text].length != 0) {
return this
}
unmergedChildren().fastForEach {
val result = it.findNonEmptyTextChild()
if (result != null) return result
}
return null
}
/**
* Dispatches hover {@link android.view.MotionEvent}s to the virtual view hierarchy when
* the Explore by Touch feature is enabled.
* <p>
* This method should be called by overriding
* {@link View#dispatchHoverEvent}:
*
* <pre>&#64;Override
* public boolean dispatchHoverEvent(MotionEvent event) {
* if (mHelper.dispatchHoverEvent(this, event) {
* return true;
* }
* return super.dispatchHoverEvent(event);
* }
* </pre>
*
* @param event The hover event to dispatch to the virtual view hierarchy.
* @return Whether the hover event was handled.
*/
fun dispatchHoverEvent(event: MotionEvent): Boolean {
if (!accessibilityManager.isEnabled() ||
!accessibilityManager.isTouchExplorationEnabled()) {
return false
}
when (event.action) {
MotionEvent.ACTION_HOVER_MOVE, MotionEvent.ACTION_HOVER_ENTER -> {
val virtualViewId: Int = getVirtualViewAt(event.getX(), event.getY())
updateHoveredVirtualView(virtualViewId)
return (virtualViewId != InvalidId)
}
MotionEvent.ACTION_HOVER_EXIT -> {
if (hoveredVirtualViewId != InvalidId) {
updateHoveredVirtualView(InvalidId)
return true
}
return false
}
else -> {
return false
}
}
}
private fun getVirtualViewAt(x: Float, y: Float): Int {
val node = view.semanticsOwner.rootSemanticsNode
val id = findVirtualViewAt(x + node.globalBounds.left,
y + node.globalBounds.top, node)
if (id == node.id) {
return AccessibilityNodeProviderCompat.HOST_VIEW_ID
}
return id
}
// TODO(b/151729467): compose accessibility getVirtualViewAt needs to be more efficient
private fun findVirtualViewAt(x: Float, y: Float, node: SemanticsNode): Int {
node.children.fastForEach {
val id = findVirtualViewAt(x, y, it)
if (id != InvalidId) {
return id
}
}
if (node.globalBounds.left < x && node.globalBounds.right > x && node
.globalBounds.top < y && node.globalBounds.bottom > y) {
return node.id
}
return InvalidId
}
/**
* Sets the currently hovered item, sending hover accessibility events as
* necessary to maintain the correct state.
*
* @param virtualViewId The virtual view id for the item currently being
* hovered, or {@link #InvalidId} if no item is hovered within
* the parent view.
*/
private fun updateHoveredVirtualView(virtualViewId: Int) {
if (hoveredVirtualViewId == virtualViewId) {
return
}
val previousVirtualViewId: Int = hoveredVirtualViewId
hoveredVirtualViewId = virtualViewId
/*
Stay consistent with framework behavior by sending ENTER/EXIT pairs
in reverse order. This is accurate as of API 18.
*/
sendEventForVirtualView(
virtualViewId,
AccessibilityEvent.TYPE_VIEW_HOVER_ENTER,
null,
null
)
sendEventForVirtualView(
previousVirtualViewId,
AccessibilityEvent.TYPE_VIEW_HOVER_EXIT,
null,
null
)
}
override fun getAccessibilityNodeProvider(host: View?): AccessibilityNodeProviderCompat {
return nodeProvider
}
/**
* Trims the text to [size] length. Returns the string as it is if the length is
* smaller than [size]. If chars at [size] - 1 and [size] is a surrogate
* pair, returns a CharSequence of length [size] - 1.
*
* @param size length of the result, should be greater than 0
*/
private fun <T : CharSequence> trimToSize(text: T?, @IntRange(from = 1) size: Int): T? {
require(size > 0)
var len = size
if (text.isNullOrEmpty() || text.length <= size) return text
if (Character.isHighSurrogate(text[size - 1]) && Character.isLowSurrogate(text[size])) {
len = size - 1
}
@Suppress("UNCHECKED_CAST")
return text.subSequence(0, len) as T
}
// TODO (in a separate cl): Called when the SemanticsNode with id semanticsNodeId disappears.
// fun clearNode(semanticsNodeId: Int) { // clear the actionIdToId and labelToActionId nodes }
private val semanticsChangeChecker = Runnable {
checkForSemanticsChanges()
checkingForSemanticsChanges = false
}
internal fun onSemanticsChange() {
if (accessibilityManager.isEnabled && !checkingForSemanticsChanges) {
checkingForSemanticsChanges = true
handler.post(semanticsChangeChecker)
}
}
private fun checkForSemanticsChanges() {
val newSemanticsNodes = view.semanticsOwner.getAllSemanticsNodesToMap()
// Structural change
sendSemanticsStructureChangeEvents(view.semanticsOwner.rootSemanticsNode, semanticsRoot)
// Property change
sendSemanticsPropertyChangeEvents(newSemanticsNodes)
// Update the cache
semanticsNodes.clear()
for (entry in newSemanticsNodes.entries) {
semanticsNodes[entry.key] = SemanticsNodeCopy(entry.value)
}
semanticsRoot = SemanticsNodeCopy(view.semanticsOwner.rootSemanticsNode)
}
private fun sendSemanticsPropertyChangeEvents(newSemanticsNodes: Map<Int, SemanticsNode>) {
for (id in newSemanticsNodes.keys) {
if (!semanticsNodes.contains(id)) {
continue
}
// We do doing this search because the new configuration is set as a whole, so we
// can't indicate which property is changed when setting the new configuration.
val newNode = newSemanticsNodes[id]
val oldNode = semanticsNodes[id]
for (entry in newNode!!.config) {
if (entry.value == oldNode!!.config.getOrNull(entry.key)) {
continue
}
when (entry.key) {
SemanticsProperties.AccessibilityValue ->
sendEventForVirtualView(
semanticsNodeIdToAccessibilityVirtualNodeId(id),
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION
)
SemanticsProperties.AccessibilityLabel ->
sendEventForVirtualView(
semanticsNodeIdToAccessibilityVirtualNodeId(id),
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
AccessibilityEvent.CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION,
entry.value as CharSequence
)
SemanticsProperties.Text -> {
// TODO(b/160184953) Add test for SemanticsProperty Text change event
if (newNode.config.contains(SemanticsActions.SetText)) {
val oldText = (oldNode.config.getOrElse(
SemanticsProperties.Text) { AnnotatedString("") }).text
val newText = (newNode.config.getOrElse(
SemanticsProperties.Text) { AnnotatedString("") }).text
var startCount = 0
// endCount records how many characters are the same from the end.
var endCount = 0
val oldTextLen = oldText.length
val newTextLen = newText.length
val minLength = oldTextLen.coerceAtMost(newTextLen)
while (startCount < minLength) {
if (oldText[startCount] != newText[startCount]) {
break
}
startCount++
}
// abcdabcd vs
// abcd
while (endCount < minLength - startCount) {
if (oldText[oldTextLen - 1 - endCount] !=
newText[newTextLen - 1 - endCount]) {
break
}
endCount++
}
val removedCount = oldTextLen - endCount - startCount
val addedCount = newTextLen - endCount - startCount
val textChangeEvent = createEvent(
semanticsNodeIdToAccessibilityVirtualNodeId(id),
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED)
textChangeEvent.fromIndex = startCount
textChangeEvent.removedCount = removedCount
textChangeEvent.addedCount = addedCount
textChangeEvent.beforeText = oldText
textChangeEvent.text.add(trimToSize(newText, ParcelSafeTextLength))
sendEvent(textChangeEvent)
} else {
sendEventForVirtualView(
semanticsNodeIdToAccessibilityVirtualNodeId(id),
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
AccessibilityEvent.CONTENT_CHANGE_TYPE_TEXT,
null
)
}
}
// do we need to overwrite TextRange equals?
SemanticsProperties.TextSelectionRange -> {
val newText = (newNode.config.getOrElse(
SemanticsProperties.Text) { AnnotatedString("") }).text
val event = createEvent(
semanticsNodeIdToAccessibilityVirtualNodeId(id),
AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED)
val textRange = newNode.config[SemanticsProperties.TextSelectionRange]
event.fromIndex = textRange.start
event.toIndex = textRange.end
event.itemCount = newText.length
event.text.add(trimToSize(newText, ParcelSafeTextLength))
sendEvent(event)
}
else -> {
// TODO(b/151840490) send the correct events when property changes
}
}
}
}
}
private fun sendSemanticsStructureChangeEvents(
newNode: SemanticsNode,
oldNode: SemanticsNodeCopy
) {
val newChildren: MutableSet<Int> = mutableSetOf()
// If any child is added, clear the subtree rooted at this node and return.
newNode.children.fastForEach { child ->
if (!oldNode.children.contains(child.id)) {
sendEventForVirtualView(
semanticsNodeIdToAccessibilityVirtualNodeId(newNode.id),
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE,
null
)
return
}
newChildren.add(child.id)
}
// If any child is deleted, clear the subtree rooted at this node and return.
for (child in oldNode.children) {
if (!newChildren.contains(child)) {
sendEventForVirtualView(
semanticsNodeIdToAccessibilityVirtualNodeId(newNode.id),
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED,
AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE,
null
)
return
}
}
newNode.children.fastForEach { child ->
sendSemanticsStructureChangeEvents(child, semanticsNodes[child.id]!!)
}
}
private fun semanticsNodeIdToAccessibilityVirtualNodeId(id: Int): Int {
if (id == view.semanticsOwner.rootSemanticsNode.id) {
return AccessibilityNodeProviderCompat.HOST_VIEW_ID
}
return id
}
private fun traverseAtGranularity(
node: SemanticsNode,
granularity: Int,
forward: Boolean,
extendSelection: Boolean
): Boolean {
val text = getIterableTextForAccessibility(node)
if (text.isNullOrEmpty()) {
return false
}
val iterator = getIteratorForGranularity(node, granularity) ?: return false
var current = getAccessibilitySelectionEnd(node)
if (current == AccessibilityCursorPositionUndefined) {
current = if (forward) 0 else text.length
}
val range = (if (forward) iterator.following(current) else iterator.preceding(current))
?: return false
val segmentStart = range[0]
val segmentEnd = range[1]
var selectionStart: Int
val selectionEnd: Int
if (extendSelection && isAccessibilitySelectionExtendable(node)) {
selectionStart = getAccessibilitySelectionStart(node)
if (selectionStart == AccessibilityCursorPositionUndefined) {
selectionStart = if (forward) segmentStart else segmentEnd
}
selectionEnd = if (forward) segmentEnd else segmentStart
} else {
selectionStart = if (forward) segmentEnd else segmentStart
selectionEnd = selectionStart
}
setAccessibilitySelection(node, selectionStart, selectionEnd)
val action =
if (forward)
AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
else AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY
sendViewTextTraversedAtGranularityEvent(node, action, granularity, segmentStart, segmentEnd)
return true
}
private fun sendViewTextTraversedAtGranularityEvent(
node: SemanticsNode,
action: Int,
granularity: Int,
fromIndex: Int,
toIndex: Int
) {
val event = createEvent(node.id,
AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY)
event.fromIndex = fromIndex
event.toIndex = toIndex
event.action = action
event.movementGranularity = granularity
event.text.add(getIterableTextForAccessibility(node))
sendEvent(event)
}
private fun setAccessibilitySelection(node: SemanticsNode, start: Int, end: Int): Boolean {
// Any widget which has custom action_set_selection needs to provide cursor
// positions, so events will be sent when cursor position change.
if (node.config.contains(SemanticsActions.SetSelection)) {
// Hide all selection controllers used for adjusting selection
// since we are doing so explicitly by other means and these
// controllers interact with how selection behaves. From TextView.java.
return node.config[SemanticsActions.SetSelection].action(start, end, false)
}
if (start == end && end == accessibilityCursorPosition) {
return false
}
if (getIterableTextForAccessibility(node) == null) {
return false
}
accessibilityCursorPosition = if (start >= 0 && start == end &&
end <= getIterableTextForAccessibility(node)!!.length) {
start
} else {
AccessibilityCursorPositionUndefined
}
sendEventForVirtualView(node.id, AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED)
return true
}
private fun getAccessibilitySelectionStart(node: SemanticsNode): Int {
// If there is AccessibilityLabel, it will be used instead of text during traversal.
if (!node.config.contains(SemanticsProperties.AccessibilityLabel) &&
node.config.contains(SemanticsProperties.TextSelectionRange)) {
return node.config[SemanticsProperties.TextSelectionRange].start
}
return accessibilityCursorPosition
}
private fun getAccessibilitySelectionEnd(node: SemanticsNode): Int {
// If there is AccessibilityLabel, it will be used instead of text during traversal.
if (!node.config.contains(SemanticsProperties.AccessibilityLabel) &&
node.config.contains(SemanticsProperties.TextSelectionRange)) {
return node.config[SemanticsProperties.TextSelectionRange].end
}
return getAccessibilitySelectionStart(node)
}
private fun isAccessibilitySelectionExtendable(node: SemanticsNode): Boolean {
// Currently only TextField is extendable. Static text may become extendable later.
return !node.config.contains(SemanticsProperties.AccessibilityLabel) &&
node.config.contains(SemanticsProperties.Text)
}
private fun getIteratorForGranularity(
node: SemanticsNode?,
granularity: Int
): AccessibilityIterators.TextSegmentIterator? {
val text = getIterableTextForAccessibility(node)
if (text.isNullOrEmpty()) {
return null
}
// TODO(b/160190186) Make sure locale is right in AccessibilityIterators.
val iterator: AccessibilityIterators.AbstractTextSegmentIterator
@Suppress("DEPRECATION")
when (granularity) {
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER -> {
iterator = AccessibilityIterators.CharacterTextSegmentIterator.getInstance(
view.context.resources.configuration.locale
)
iterator.initialize(text)
}
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD -> {
iterator = AccessibilityIterators.WordTextSegmentIterator.getInstance(
view.context.resources.configuration.locale
)
iterator.initialize(text)
}
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH -> {
iterator = AccessibilityIterators.ParagraphTextSegmentIterator.getInstance()
iterator.initialize(text)
}
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE,
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PAGE -> {
// Line and page granularity are only for static text or text field.
if (node == null || !node.config.contains(SemanticsProperties.Text) ||
!node.config.contains(SemanticsActions.GetTextLayoutResult)) {
return null
}
// TODO(b/157474582): Note now it only works for single Text/TextField until we
// fix the merging issue.
val textLayoutResults = mutableListOf<TextLayoutResult>()
val textLayoutResult: TextLayoutResult
if (node.config[SemanticsActions.GetTextLayoutResult].action(textLayoutResults)) {
textLayoutResult = textLayoutResults[0]
} else {
return null
}
if (granularity == AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE) {
iterator = AccessibilityIterators.LineTextSegmentIterator.getInstance()
iterator.initialize(text, textLayoutResult)
} else {
iterator = AccessibilityIterators.PageTextSegmentIterator.getInstance()
// TODO: the node should be text/textfield node instead of the current node.
iterator.initialize(text, textLayoutResult, node)
}
}
else -> return null
}
return iterator
}
/**
* Gets the text reported for accessibility purposes.
*
* @return The accessibility text.
*/
private fun getIterableTextForAccessibility(node: SemanticsNode?): String? {
if (node == null) {
return null
}
// Note in android framework, TextView set this to its text. This is changed to
// prioritize content description, even for Text.
if (node.config.contains(SemanticsProperties.AccessibilityLabel)) {
return node.config[SemanticsProperties.AccessibilityLabel]
}
if (node.config.contains(SemanticsProperties.Text)) {
return node.config[SemanticsProperties.Text].text
}
return null
}
// TODO(b/160820721): use AccessibilityNodeProviderCompat instead of AccessibilityNodeProvider
inner class MyNodeProvider : AccessibilityNodeProvider() {
override fun createAccessibilityNodeInfo(virtualViewId: Int):
AccessibilityNodeInfo? {
return createNodeInfo(virtualViewId)
}
override fun performAction(
virtualViewId: Int,
action: Int,
arguments: Bundle?
): Boolean {
return performActionHelper(virtualViewId, action, arguments)
}
override fun addExtraDataToAccessibilityNodeInfo(
virtualViewId: Int,
info: AccessibilityNodeInfo,
extraDataKey: String,
arguments: Bundle?
) {
addExtraDataToAccessibilityNodeInfoHelper(virtualViewId, info, extraDataKey, arguments)
}
}
}