Change announcement on 'isPassword' toggle
This changes the announcement when we toggle the password visibility icon in the material TextField.
This change follows the View-based implementation: it sends the TYPE_VIEW_TEXT_SELECTION_CHANGED. In View there's another TYPE_VIEW_TEXT_SELECTION_CHANGED event with fromIndex=0 and toIndex=0 which most likely come from setting the span. We don't send this event in Compose (only when the real selection changes). Therefore to mirror the behavior we need to send it. As a result two TYPE_VIEW_TEXT_SELECTION_CHANGED events are sent - one with the cursor position at 0 and second with the correct position.
Bug: 247891690
Test: manually in demo with Talkback on
Test: new tests in AndroidComposeViewAccessibilityDelegateCompatTest
Change-Id: If75a1d48df37a0475367aacfca925d39084d38f1
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt
index 1b57cbe..2ee2e0c 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidAccessibilityTest.kt
@@ -87,6 +87,7 @@
import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat
import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.ClassName
import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.InvalidId
+import androidx.compose.ui.platform.AndroidComposeViewAccessibilityDelegateCompat.Companion.TextFieldClassName
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.getAllUncoveredSemanticsNodesToMap
@@ -1408,6 +1409,7 @@
textFieldNode.id,
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
)
+ textEvent.className = TextFieldClassName
textEvent.fromIndex = initialText.length
textEvent.removedCount = 0
textEvent.addedCount = text.length - initialText.length
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt
index f375f89..9e1503d 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt
@@ -66,6 +66,7 @@
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.onLongClick
+import androidx.compose.ui.semantics.password
import androidx.compose.ui.semantics.pasteText
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.role
@@ -1296,6 +1297,124 @@
)
}
+ @Test
+ fun passwordVisibilityToggle_fromInvisibleToVisible_doNotSendTextChangeEvent() {
+ sendTextSemanticsChangeEvent(oldNodePassword = true, newNodePassword = false)
+
+ verify(container, never()).requestSendAccessibilityEvent(
+ eq(androidComposeView),
+ argThat(
+ ArgumentMatcher {
+ it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+ }
+ )
+ )
+ }
+
+ @Test
+ fun passwordVisibilityToggle_fromVisibleToInvisible_doNotSendTextChangeEvent() {
+ sendTextSemanticsChangeEvent(oldNodePassword = false, newNodePassword = true)
+
+ verify(container, never()).requestSendAccessibilityEvent(
+ eq(androidComposeView),
+ argThat(
+ ArgumentMatcher {
+ it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+ }
+ )
+ )
+ }
+
+ @Test
+ fun passwordVisibilityToggle_fromInvisibleToVisible_sendTwoSelectionEvents() {
+ sendTextSemanticsChangeEvent(oldNodePassword = true, newNodePassword = false)
+
+ verify(container, times(2)).requestSendAccessibilityEvent(
+ eq(androidComposeView),
+ argThat(
+ ArgumentMatcher {
+ it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED
+ }
+ )
+ )
+ }
+
+ @Test
+ fun passwordVisibilityToggle_fromVisibleToInvisible_sendTwoSelectionEvents() {
+ sendTextSemanticsChangeEvent(oldNodePassword = false, newNodePassword = true)
+
+ verify(container, times(2)).requestSendAccessibilityEvent(
+ eq(androidComposeView),
+ argThat(
+ ArgumentMatcher {
+ it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED
+ }
+ )
+ )
+ }
+
+ @Test
+ fun textChanged_sendTextChangeEvent() {
+ sendTextSemanticsChangeEvent(oldNodePassword = false, newNodePassword = false)
+
+ verify(container, times(1)).requestSendAccessibilityEvent(
+ eq(androidComposeView),
+ argThat(
+ ArgumentMatcher {
+ it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+ }
+ )
+ )
+ }
+
+ @Test
+ fun textChanged_passwordNode_sendTextChangeEvent() {
+ sendTextSemanticsChangeEvent(oldNodePassword = true, newNodePassword = true)
+
+ verify(container, times(1)).requestSendAccessibilityEvent(
+ eq(androidComposeView),
+ argThat(
+ ArgumentMatcher {
+ it.eventType == AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+ }
+ )
+ )
+ }
+
+ private fun sendTextSemanticsChangeEvent(oldNodePassword: Boolean, newNodePassword: Boolean) {
+ val nodeId = 1
+ val oldTextNode = createSemanticsNodeWithProperties(nodeId, true) {
+ setText { true }
+ if (oldNodePassword) password()
+ textSelectionRange = TextRange(4)
+ editableText = AnnotatedString(
+ when {
+ oldNodePassword && !newNodePassword -> "****"
+ !oldNodePassword && newNodePassword -> "1234"
+ !oldNodePassword && !newNodePassword -> "1234"
+ else -> "1234"
+ }
+ )
+ }
+ accessibilityDelegate.previousSemanticsNodes[nodeId] =
+ AndroidComposeViewAccessibilityDelegateCompat.SemanticsNodeCopy(oldTextNode, mapOf())
+
+ val newTextNode = createSemanticsNodeWithAdjustedBoundsWithProperties(nodeId, true) {
+ setText { true }
+ if (newNodePassword) password()
+ textSelectionRange = TextRange(4)
+ editableText = AnnotatedString(
+ when {
+ oldNodePassword && !newNodePassword -> "1234"
+ !oldNodePassword && newNodePassword -> "****"
+ !oldNodePassword && !newNodePassword -> "1235"
+ else -> "1235"
+ }
+ )
+ }
+ accessibilityDelegate.sendSemanticsPropertyChangeEvents(mapOf(nodeId to newTextNode))
+ }
+
@OptIn(ExperimentalComposeUiApi::class)
private fun createSemanticsNodeWithProperties(
id: Int,
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
index ffb5830..facce63 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt
@@ -124,6 +124,8 @@
/** Virtual node identifier value for invalid nodes. */
const val InvalidId = Integer.MIN_VALUE
const val ClassName = "android.view.View"
+ const val TextFieldClassName = "android.widget.EditText"
+ const val TextClassName = "android.widget.TextView"
const val LogTag = "AccessibilityDelegate"
const val ExtraDataTestTagKey = "androidx.compose.ui.semantics.testTag"
@@ -291,7 +293,7 @@
*/
@VisibleForTesting
internal class SemanticsNodeCopy(
- semanticsNode: SemanticsNode,
+ val semanticsNode: SemanticsNode,
currentSemanticsNodes: Map<Int, SemanticsNodeWithAdjustedBounds>
) {
val unmergedConfig = semanticsNode.unmergedConfig
@@ -523,10 +525,10 @@
}
}
if (semanticsNode.isTextField) {
- info.className = "android.widget.EditText"
+ info.className = TextFieldClassName
}
if (semanticsNode.config.contains(SemanticsProperties.Text)) {
- info.className = "android.widget.TextView"
+ info.className = TextClassName
}
info.packageName = view.context.packageName
@@ -1200,7 +1202,7 @@
fromIndex: Int?,
toIndex: Int?,
itemCount: Int?,
- text: String?
+ text: CharSequence?
): AccessibilityEvent {
return createEvent(
virtualViewId,
@@ -2042,10 +2044,12 @@
)
}
SemanticsProperties.EditableText -> {
- // TODO(b/160184953) Add test for SemanticsProperty Text change event
if (newNode.isTextField) {
+
val oldText = oldNode.unmergedConfig.getTextForTextField() ?: ""
val newText = newNode.unmergedConfig.getTextForTextField() ?: ""
+ val trimmedNewText = trimToSize(newText, ParcelSafeTextLength)
+
var startCount = 0
// endCount records how many characters are the same from the end.
var endCount = 0
@@ -2070,16 +2074,53 @@
}
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)
+
+ // (b/247891690) We won't send a text change event when we only toggle
+ // the password visibility of the node
+ val becamePasswordNode = oldNode.semanticsNode.isTextField &&
+ !oldNode.semanticsNode.isPassword && newNode.isPassword
+ val becameNotPasswordNode = oldNode.semanticsNode.isTextField &&
+ oldNode.semanticsNode.isPassword && !newNode.isPassword
+ val event = if (becamePasswordNode || becameNotPasswordNode) {
+ // (b/247891690) password visibility toggle is handled by a
+ // selection event. Because internally Talkback already has the
+ // correct cursor position, there will be no announcement.
+ // Therefore we first send the "cursor reset" event with the
+ // selection at (0, 0) and right after that we will send the event
+ // with the correct cursor position. This behaves similarly to the
+ // View-based material EditText which also sends two selection
+ // events
+ createTextSelectionChangedEvent(
+ virtualViewId = semanticsNodeIdToAccessibilityVirtualNodeId(id),
+ fromIndex = 0,
+ toIndex = 0,
+ itemCount = newTextLen,
+ text = trimmedNewText
+ )
+ } else {
+ createEvent(
+ semanticsNodeIdToAccessibilityVirtualNodeId(id),
+ AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED
+ ).apply {
+ this.fromIndex = startCount
+ this.removedCount = removedCount
+ this.addedCount = addedCount
+ this.beforeText = oldText
+ this.text.add(trimmedNewText)
+ }
+ }
+ event.className = TextFieldClassName
+ sendEvent(event)
+
+ // (b/247891690) second event with the correct cursor position (see
+ // comment above for more details)
+ if (becamePasswordNode || becameNotPasswordNode) {
+ val textRange =
+ newNode.unmergedConfig[SemanticsProperties.TextSelectionRange]
+ event.fromIndex = textRange.start
+ event.toIndex = textRange.end
+ sendEvent(event)
+ }
} else {
sendEventForVirtualView(
semanticsNodeIdToAccessibilityVirtualNodeId(id),