blob: 77e5d5110854afbd451e7a312baa9fc0e39b0948 [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
import android.os.Build
import android.text.SpannableString
import android.view.ViewGroup
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.widget.FrameLayout
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toAndroidRect
import androidx.compose.ui.node.InnerNodeCoordinator
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.node.SemanticsModifierNode
import androidx.compose.ui.platform.AndroidComposeView
import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.SemanticsNodeWithAdjustedBounds
import androidx.compose.ui.platform.getAllUncoveredSemanticsNodesToMap
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.ScrollAxisRange
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsModifierCore
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.copyText
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.cutText
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.error
import androidx.compose.ui.semantics.editableText
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.focused
import androidx.compose.ui.semantics.getTextLayoutResult
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.horizontalScrollAxisRange
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.onLongClick
import androidx.compose.ui.semantics.pasteText
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.setProgress
import androidx.compose.ui.semantics.setSelection
import androidx.compose.ui.semantics.setText
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.semantics.text
import androidx.compose.ui.semantics.textSelectionRange
import androidx.compose.ui.semantics.verticalScrollAxisRange
import androidx.compose.ui.test.TestActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextRange
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import androidx.test.filters.MediumTest
import androidx.test.platform.app.InstrumentationRegistry
import com.nhaarman.mockitokotlin2.argThat
import com.nhaarman.mockitokotlin2.doReturn
import com.nhaarman.mockitokotlin2.eq
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.spy
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatcher
import org.mockito.ArgumentMatchers
@MediumTest
@RunWith(AndroidJUnit4::class)
class AndroidComposeViewAccessibilityDelegateCompatTest {
@get:Rule
val rule = createAndroidComposeRule<TestActivity>()
private lateinit var accessibilityDelegate: AndroidComposeViewAccessibilityDelegateCompat
private lateinit var container: ViewGroup
private lateinit var androidComposeView: AndroidComposeView
private lateinit var info: AccessibilityNodeInfoCompat
@Before
fun setup() {
// Use uiAutomation to enable accessibility manager.
InstrumentationRegistry.getInstrumentation().uiAutomation
rule.activityRule.scenario.onActivity {
androidComposeView = AndroidComposeView(it)
container = spy(FrameLayout(it)) {
on {
onRequestSendAccessibilityEvent(
ArgumentMatchers.any(),
ArgumentMatchers.any()
)
} doReturn false
}
container.addView(androidComposeView)
accessibilityDelegate = AndroidComposeViewAccessibilityDelegateCompat(
androidComposeView
)
accessibilityDelegate.accessibilityForceEnabledForTesting = true
}
info = AccessibilityNodeInfoCompat.obtain()
}
@Test
@OptIn(ExperimentalComposeUiApi::class)
fun testPopulateAccessibilityNodeInfoProperties_general() {
val clickActionLabel = "click"
val dismissActionLabel = "dismiss"
val expandActionLabel = "expand"
val collapseActionLabel = "collapse"
val stateDescription = "checked"
val resourceName = "myResourceName"
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
this.stateDescription = stateDescription
testTag = resourceName
testTagsAsResourceId = true
heading()
onClick(clickActionLabel) { true }
dismiss(dismissActionLabel) { true }
expand(expandActionLabel) { true }
collapse(collapseActionLabel) { true }
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.view.View", info.className)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_CLICK,
clickActionLabel
)
)
)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_DISMISS,
dismissActionLabel
)
)
)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_EXPAND,
expandActionLabel
)
)
)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_COLLAPSE,
collapseActionLabel
)
)
)
val stateDescriptionResult = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
info.unwrap().stateDescription
}
Build.VERSION.SDK_INT >= 19 -> {
info.extras.getCharSequence(
"androidx.view.accessibility.AccessibilityNodeInfoCompat.STATE_DESCRIPTION_KEY"
)
}
else -> {
null
}
}
assertEquals(stateDescription, stateDescriptionResult)
assertEquals(resourceName, info.viewIdResourceName)
assertTrue(info.isHeading)
assertTrue(info.isClickable)
assertTrue(info.isVisibleToUser)
assertTrue(info.isImportantForAccessibility)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_screenReaderFocusable_mergingDescendants() {
val node = createSemanticsNodeWithProperties(1, true) {}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, node)
assertTrue(info.isScreenReaderFocusable)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_screenReaderFocusable_notMergingDescendants() {
val node = createSemanticsNodeWithProperties(1, false) {}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, node)
assertFalse(info.isScreenReaderFocusable)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_screenReaderFocusable_speakable() {
val node = createSemanticsNodeWithProperties(1, false) {
text = AnnotatedString("Example text")
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, node)
assertTrue(info.isScreenReaderFocusable)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_disabled() {
rule.setContent {
LocalClipboardManager.current.setText(AnnotatedString("test"))
}
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
disabled()
editableText = AnnotatedString("text")
horizontalScrollAxisRange = ScrollAxisRange({ 0f }, { 5f })
onClick { true }
onLongClick { true }
copyText { true }
pasteText { true }
cutText { true }
setText { true }
setSelection { _, _, _ -> true }
dismiss { true }
expand { true }
collapse { true }
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertTrue(info.isClickable)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK
)
)
assertTrue(info.isLongClickable)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_LONG_CLICK
)
)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_COPY
)
)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_PASTE
)
)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CUT
)
)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_TEXT
)
)
// This is the default ACTION_SET_SELECTION.
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SET_SELECTION
)
)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_DISMISS
)
)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_EXPAND
)
)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_COLLAPSE
)
)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_FORWARD
)
)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_SCROLL_RIGHT
)
)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_buttonRole() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
role = Role.Button
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.widget.Button", info.className)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_switchRole() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
role = Role.Switch
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.widget.Switch", info.className)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_checkBoxRole() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
role = Role.Checkbox
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.widget.CheckBox", info.className)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_radioButtonRole() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
role = Role.RadioButton
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.widget.RadioButton", info.className)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_tabRole() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
role = Role.Tab
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("Tab", info.roleDescription)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_imageRole() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
role = Role.Image
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.widget.ImageView", info.className)
}
@Test
fun nodeWithTextAndLayoutResult_className_textView() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
text = AnnotatedString("")
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.widget.TextView", info.className)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_liveRegion() {
var semanticsNode = createSemanticsNodeWithProperties(1, true) {
liveRegion = LiveRegionMode.Polite
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals(ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE, info.liveRegion)
info = AccessibilityNodeInfoCompat.obtain()
semanticsNode = createSemanticsNodeWithProperties(1, true) {
liveRegion = LiveRegionMode.Assertive
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals(ViewCompat.ACCESSIBILITY_LIVE_REGION_ASSERTIVE, info.liveRegion)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_SeekBar() {
val setProgressActionLabel = "setProgress"
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
progressBarRangeInfo = ProgressBarRangeInfo(0.5f, 0f..1f, 6)
setProgress(setProgressActionLabel) { true }
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.widget.SeekBar", info.className)
assertEquals(
AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT,
info.rangeInfo.type
)
assertEquals(0.5f, info.rangeInfo.current)
assertEquals(0f, info.rangeInfo.min)
assertEquals(1f, info.rangeInfo.max)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
android.R.id.accessibilityActionSetProgress,
setProgressActionLabel
)
)
)
}
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_textField() {
val setSelectionActionLabel = "setSelection"
val setTextActionLabel = "setText"
val text = "hello"
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
this.editableText = AnnotatedString(text)
this.textSelectionRange = TextRange(1)
this.focused = true
getTextLayoutResult { true }
setText(setTextActionLabel) { true }
setSelection(setSelectionActionLabel) { _, _, _ -> true }
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals("android.widget.EditText", info.className)
assertEquals(SpannableString(text), info.text)
assertTrue(info.isFocusable)
assertTrue(info.isFocused)
assertTrue(info.isEditable)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_SET_SELECTION,
setSelectionActionLabel
)
)
)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_SET_TEXT,
setTextActionLabel
)
)
)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat
.ACTION_NEXT_AT_MOVEMENT_GRANULARITY
)
)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat
.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY
)
)
if (Build.VERSION.SDK_INT >= 26) {
assertEquals(
listOf(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY),
info.unwrap().availableExtraData
)
}
}
@Test
fun testMovementGranularities_textField_focused() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
this.editableText = AnnotatedString("text")
this.textSelectionRange = TextRange(1)
this.focused = true
getTextLayoutResult { true }
setText { true }
setSelection { _, _, _ -> true }
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals(
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_LINE or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PAGE,
info.movementGranularities
)
}
@Test
fun testMovementGranularities_textField_notFocused() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
this.editableText = AnnotatedString("text")
this.textSelectionRange = TextRange(1)
getTextLayoutResult { true }
setText { true }
setSelection { _, _, _ -> true }
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertEquals(
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_CHARACTER or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_WORD or
AccessibilityNodeInfoCompat.MOVEMENT_GRANULARITY_PARAGRAPH,
info.movementGranularities
)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_setContentInvalid_customDescription() {
val errorDescription = "Invalid format"
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
error(errorDescription)
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertTrue(info.isContentInvalid)
assertEquals(errorDescription, info.error)
}
@Test
fun testPopulateAccessibilityNodeInfoProperties_setContentInvalid_emptyDescription() {
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
error("")
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertTrue(info.isContentInvalid)
assertTrue(info.error.isEmpty())
}
@Test
fun test_PasteAction_ifFocused() {
rule.setContent {
LocalClipboardManager.current.setText(AnnotatedString("test"))
}
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
focused = true
pasteText {
true
}
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertTrue(info.isFocused)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_PASTE,
null
)
)
)
}
@Test
fun test_noPasteAction_ifUnfocused() {
rule.setContent {
LocalClipboardManager.current.setText(AnnotatedString("test"))
}
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
pasteText {
true
}
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
assertFalse(info.isFocused)
assertFalse(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_PASTE,
null
)
)
)
}
@Test
fun testActionCanBeNull() {
val actionLabel = "send"
val semanticsNode = createSemanticsNodeWithProperties(1, true) {
onClick(label = actionLabel, action = null)
}
accessibilityDelegate.populateAccessibilityNodeInfoProperties(1, info, semanticsNode)
// When action is null here, should we still think it is clickable? Should we add the action
// to AccessibilityNodeInfo?
assertTrue(info.isClickable)
assertTrue(
containsAction(
info,
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfoCompat.ACTION_CLICK,
actionLabel
)
)
)
}
@Test
@FlakyTest(bugId = 195287742)
fun sendScrollEvent_byStateObservation() {
var scrollValue by mutableStateOf(0f, structuralEqualityPolicy())
var scrollMaxValue by mutableStateOf(100f, structuralEqualityPolicy())
val semanticsNode = createSemanticsNodeWithProperties(1, false) {
verticalScrollAxisRange = ScrollAxisRange({ scrollValue }, { scrollMaxValue })
}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
semanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = SemanticsNodeWithAdjustedBounds(
semanticsNode,
android.graphics.Rect()
)
try {
accessibilityDelegate.view.snapshotObserver.startObserving()
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
Snapshot.notifyObjectsInitialized()
scrollValue = 1f
Snapshot.sendApplyNotifications()
} finally {
accessibilityDelegate.view.snapshotObserver.stopObserving()
}
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE
}
)
)
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED &&
it.scrollY == 1 &&
it.maxScrollY == 100 &&
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
it.scrollDeltaY == 1
} else {
true
}
}
)
)
}
@Test
fun sendWindowContentChangeUndefinedEventByDefault_whenPropertyAdded() {
val oldSemanticsNode = createSemanticsNodeWithProperties(1, false) {}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
oldSemanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = createSemanticsNodeWithAdjustedBoundsWithProperties(1, false) {
disabled()
}
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
@Test
fun sendWindowContentChangeUndefinedEventByDefault_whenPropertyRemoved() {
val oldSemanticsNode = createSemanticsNodeWithProperties(1, false) {
disabled()
}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
oldSemanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = createSemanticsNodeWithAdjustedBoundsWithProperties(1, false) {}
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
@Test
fun sendWindowContentChangeUndefinedEventByDefault_onlyOnce_whenMultiplePropertiesChange() {
val oldSemanticsNode = createSemanticsNodeWithProperties(1, false) {
disabled()
}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
oldSemanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = createSemanticsNodeWithAdjustedBoundsWithProperties(1, false) {
onClick { true }
}
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
@Test
fun sendWindowContentChangeUndefinedEventByDefault_standardActionWithTheSameLabel() {
val label = "label"
val oldSemanticsNode = createSemanticsNodeWithProperties(1, false) {
onClick(label = label) { true }
}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
oldSemanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = createSemanticsNodeWithAdjustedBoundsWithProperties(1, false) {
onClick(label = label) { true }
}
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
verify(container, never()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
@Test
fun sendWindowContentChangeUndefinedEventByDefault_standardActionWithDifferentLabels() {
val labelOld = "labelOld"
val labelNew = "labelNew"
val oldSemanticsNode = createSemanticsNodeWithProperties(1, false) {
onClick(label = labelOld) { true }
}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
oldSemanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = createSemanticsNodeWithAdjustedBoundsWithProperties(1, false) {
onClick(label = labelNew) { true }
}
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
@Test
fun sendWindowContentChangeUndefinedEventByDefault_customActionWithTheSameLabel() {
val label = "label"
val oldSemanticsNode = createSemanticsNodeWithProperties(1, false) {
customActions = listOf(CustomAccessibilityAction(label) { true })
}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
oldSemanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = createSemanticsNodeWithAdjustedBoundsWithProperties(1, false) {
customActions = listOf(CustomAccessibilityAction(label) { true })
}
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
verify(container, never()).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
@Test
fun sendWindowContentChangeUndefinedEventByDefault_customActionWithDifferentLabels() {
val labelOld = "labelOld"
val labelNew = "labelNew"
val oldSemanticsNode = createSemanticsNodeWithProperties(1, false) {
customActions = listOf(CustomAccessibilityAction(labelOld) { true })
}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
oldSemanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = createSemanticsNodeWithAdjustedBoundsWithProperties(1, false) {
customActions = listOf(CustomAccessibilityAction(labelNew) { true })
}
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
verify(container, times(1)).requestSendAccessibilityEvent(
eq(androidComposeView),
argThat(
ArgumentMatcher {
it.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED &&
it.contentChangeTypes == AccessibilityEvent.CONTENT_CHANGE_TYPE_UNDEFINED
}
)
)
}
@Test
fun testUncoveredNodes_notPlacedNodes_notIncluded() {
val nodes = SemanticsOwner(
LayoutNode().also {
it.modifier = SemanticsModifierCore(
mergeDescendants = false,
clearAndSetSemantics = false,
properties = {}
)
}
).getAllUncoveredSemanticsNodesToMap()
assertEquals(0, nodes.size)
}
@Test
fun testUncoveredNodes_zeroBoundsRoot_included() {
val nodes = SemanticsOwner(androidComposeView.root).getAllUncoveredSemanticsNodesToMap()
assertEquals(1, nodes.size)
assertEquals(AccessibilityNodeProviderCompat.HOST_VIEW_ID, nodes.keys.first())
assertEquals(
Rect.Zero.toAndroidRect(),
nodes[AccessibilityNodeProviderCompat.HOST_VIEW_ID]!!.adjustedBounds
)
}
@Test
fun testContentDescriptionCastSuccess() {
val oldSemanticsNode = createSemanticsNodeWithProperties(1, true) {
}
accessibilityDelegate.previousSemanticsNodes[1] =
AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(
oldSemanticsNode,
mapOf()
)
val newNodes = mutableMapOf<Int, SemanticsNodeWithAdjustedBounds>()
newNodes[1] = createSemanticsNodeWithAdjustedBoundsWithProperties(1, true) {
this.contentDescription = "Hello" // To trigger content description casting
}
accessibilityDelegate.sendSemanticsPropertyChangeEvents(newNodes)
}
@Test
fun canScroll_returnsFalse_whenPositionInvalid() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
horizontalScrollAxisRange = ScrollAxisRange(
value = { 0f },
maxValue = { 1f },
reverseScrolling = false
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = 1,
position = Offset.Unspecified
)
)
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = -1,
position = Offset.Unspecified
)
)
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = 0,
position = Offset.Unspecified
)
)
}
@Test
fun canScroll_returnsTrue_whenHorizontalScrollableNotAtLimit() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
horizontalScrollAxisRange = ScrollAxisRange(
value = { 0.5f },
maxValue = { 1f },
reverseScrolling = false
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
// Should be scrollable in both directions.
assertTrue(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = 1,
position = Offset(50f, 50f)
)
)
assertTrue(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = 0,
position = Offset(50f, 50f)
)
)
assertTrue(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = -1,
position = Offset(50f, 50f)
)
)
}
@Test
fun canScroll_returnsTrue_whenVerticalScrollableNotAtLimit() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
verticalScrollAxisRange = ScrollAxisRange(
value = { 0.5f },
maxValue = { 1f },
reverseScrolling = false
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
// Should be scrollable in both directions.
assertTrue(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = true,
direction = -1,
position = Offset(50f, 50f)
)
)
assertTrue(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = true,
direction = 0,
position = Offset(50f, 50f)
)
)
assertTrue(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = true,
direction = 1,
position = Offset(50f, 50f)
)
)
}
@Test
fun canScroll_returnsFalse_whenHorizontalScrollable_whenScrolledRightAndAtLimit() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
horizontalScrollAxisRange = ScrollAxisRange(
value = { 1f },
maxValue = { 1f },
reverseScrolling = false
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = 1,
position = Offset(50f, 50f)
)
)
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = 0,
position = Offset(50f, 50f)
)
)
}
@Test
fun canScroll_returnsFalse_whenHorizontalScrollable_whenScrolledLeftAndAtLimit() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
horizontalScrollAxisRange = ScrollAxisRange(
value = { 0f },
maxValue = { 1f },
reverseScrolling = false
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = -1,
position = Offset(50f, 50f)
)
)
}
@Test
fun canScroll_returnsFalse_whenVerticalScrollable_whenScrolledDownAndAtLimit() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
verticalScrollAxisRange = ScrollAxisRange(
value = { 1f },
maxValue = { 1f },
reverseScrolling = false
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = true,
direction = 1,
position = Offset(50f, 50f)
)
)
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = true,
direction = 0,
position = Offset(50f, 50f)
)
)
}
@Test
fun canScroll_returnsFalse_whenVerticalScrollable_whenScrolledUpAndAtLimit() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
verticalScrollAxisRange = ScrollAxisRange(
value = { 0f },
maxValue = { 1f },
reverseScrolling = false
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = true,
direction = -1,
position = Offset(50f, 50f)
)
)
}
@Test
fun canScroll_respectsReverseDirection() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
horizontalScrollAxisRange = ScrollAxisRange(
value = { 0f },
maxValue = { 1f },
reverseScrolling = true
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
assertTrue(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
// Scroll left, even though value is 0.
direction = -1,
position = Offset(50f, 50f)
)
)
}
@Test
fun canScroll_returnsFalse_forVertical_whenScrollableIsHorizontal() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
horizontalScrollAxisRange = ScrollAxisRange(
value = { 0.5f },
maxValue = { 1f },
reverseScrolling = true
)
}.apply {
adjustedBounds.set(0, 0, 100, 100)
}
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = true,
direction = 1,
position = Offset(50f, 50f)
)
)
}
@Test
fun canScroll_returnsFalse_whenTouchIsOutsideBounds() {
val semanticsNode = createSemanticsNodeWithAdjustedBoundsWithProperties(
id = 1,
mergeDescendants = true
) {
horizontalScrollAxisRange = ScrollAxisRange(
value = { 0.5f },
maxValue = { 1f },
reverseScrolling = true
)
}.apply {
adjustedBounds.set(0, 0, 50, 50)
}
assertFalse(
accessibilityDelegate.canScroll(
currentSemanticsNodes = listOf(semanticsNode),
vertical = false,
direction = 1,
position = Offset(100f, 100f)
)
)
}
@OptIn(ExperimentalComposeUiApi::class)
private fun createSemanticsNodeWithProperties(
id: Int,
mergeDescendants: Boolean,
properties: (SemanticsPropertyReceiver.() -> Unit)
): SemanticsNode {
val layoutNode = LayoutNode(semanticsId = id)
val nodeCoordinator = InnerNodeCoordinator(layoutNode)
val modifierNode = object : SemanticsModifierNode, Modifier.Node() {
override val semanticsConfiguration = SemanticsConfiguration().also {
it.isMergingSemanticsOfDescendants = mergeDescendants
it.properties()
}
}
modifierNode.updateCoordinator(nodeCoordinator)
return SemanticsNode(
modifierNode,
true,
layoutNode
)
}
private fun createSemanticsNodeWithAdjustedBoundsWithProperties(
id: Int,
mergeDescendants: Boolean,
properties: (SemanticsPropertyReceiver.() -> Unit)
): SemanticsNodeWithAdjustedBounds {
return SemanticsNodeWithAdjustedBounds(
createSemanticsNodeWithProperties(id, mergeDescendants, properties),
android.graphics.Rect()
)
}
private fun containsAction(
info: AccessibilityNodeInfoCompat,
action: AccessibilityNodeInfoCompat.AccessibilityActionCompat
): Boolean {
for (a in info.actionList) {
if (a.id == action.id && a.label == action.label) {
return true
}
}
return false
}
}